BEGIN; -- Direct multi-row team mutations cannot preserve ownership invariants. All -- writes below are routed through locked SECURITY DEFINER RPCs. DROP POLICY IF EXISTS "teams_insert_own" ON public.teams; DROP POLICY IF EXISTS "team_members_insert_admin" ON public.team_members; DROP POLICY IF EXISTS "team_members_update_admin" ON public.team_members; DROP POLICY IF EXISTS "team_members_delete_admin_or_self" ON public.team_members; DROP POLICY IF EXISTS "team_invites_insert_admin" ON public.team_invites; DROP POLICY IF EXISTS "team_invites_read" ON public.team_invites; DROP POLICY IF EXISTS "team_invites_delete" ON public.team_invites; CREATE TEMP TABLE duplicate_active_team_invites ON COMMIT DROP AS SELECT id, row_number() OVER ( PARTITION BY team_id, lower(btrim(email)) ORDER BY created_at DESC, id ) AS duplicate_rank FROM public.team_invites WHERE accepted_at IS NULL; DELETE FROM public.team_invites AS invite USING duplicate_active_team_invites AS duplicate WHERE invite.id = duplicate.id AND duplicate.duplicate_rank > 1; CREATE UNIQUE INDEX IF NOT EXISTS idx_team_invites_active_email ON public.team_invites(team_id, lower(btrim(email))) WHERE accepted_at IS NULL; CREATE OR REPLACE FUNCTION public.create_team(team_name text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); normalized_name text := btrim(team_name); created_team public.teams; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF normalized_name = '' OR char_length(normalized_name) > 80 THEN RAISE EXCEPTION 'invalid_team_name' USING ERRCODE = '22023'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73041)); IF ( SELECT count(*) FROM public.teams WHERE owner_id = current_user_id ) >= 25 THEN RAISE EXCEPTION 'team_limit_reached' USING ERRCODE = '54000'; END IF; INSERT INTO public.teams (name, owner_id) VALUES (normalized_name, current_user_id) RETURNING * INTO created_team; INSERT INTO public.team_members (team_id, user_id, role) VALUES (created_team.id, current_user_id, 'owner'); RETURN jsonb_build_object( 'id', created_team.id, 'name', created_team.name, 'owner_id', created_team.owner_id, 'role', 'owner', 'member_count', 1, 'created_at', created_team.created_at, 'updated_at', created_team.updated_at ); END; $$; CREATE OR REPLACE FUNCTION public.create_team_invite( target_team_id uuid, invited_email text, invited_role text DEFAULT 'member' ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); current_user_email text; caller_team_role text; normalized_email text := lower(btrim(invited_email)); existing_invite public.team_invites; created_invite public.team_invites; invite_token text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF target_team_id IS NULL OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$' OR char_length(normalized_email) > 254 OR invited_role NOT IN ('admin', 'member') THEN RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023'; END IF; SELECT lower(email) INTO current_user_email FROM auth.users WHERE id = current_user_id; IF current_user_email = normalized_email THEN RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023'; END IF; SELECT role INTO caller_team_role FROM public.team_members WHERE team_id = target_team_id AND user_id = current_user_id; IF caller_team_role NOT IN ('owner', 'admin') THEN RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501'; END IF; IF invited_role = 'admin' AND caller_team_role <> 'owner' THEN RAISE EXCEPTION 'owner_required_for_admin_invite' USING ERRCODE = '42501'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text || ':' || normalized_email, 73042)); IF ( SELECT count(*) FROM public.team_invites WHERE invited_by = current_user_id AND created_at >= now() - interval '24 hours' ) >= 50 THEN RAISE EXCEPTION 'invite_rate_limited' USING ERRCODE = '54000'; END IF; IF EXISTS ( SELECT 1 FROM public.team_members AS member JOIN auth.users AS account ON account.id = member.user_id WHERE member.team_id = target_team_id AND lower(account.email) = normalized_email ) THEN RAISE EXCEPTION 'already_team_member' USING ERRCODE = '23505'; END IF; SELECT * INTO existing_invite FROM public.team_invites WHERE team_id = target_team_id AND lower(email) = normalized_email AND accepted_at IS NULL ORDER BY created_at DESC LIMIT 1 FOR UPDATE; IF existing_invite.id IS NOT NULL AND existing_invite.expires_at > now() THEN IF existing_invite.role <> invited_role THEN UPDATE public.team_invites SET role = invited_role WHERE id = existing_invite.id RETURNING * INTO existing_invite; END IF; RETURN jsonb_build_object( 'id', existing_invite.id, 'team_id', existing_invite.team_id, 'email', existing_invite.email, 'token', existing_invite.token, 'role', existing_invite.role, 'expires_at', existing_invite.expires_at, 'duplicate', true ); END IF; IF existing_invite.id IS NOT NULL THEN DELETE FROM public.team_invites WHERE id = existing_invite.id; END IF; invite_token := public.generate_invite_token(); INSERT INTO public.team_invites ( team_id, invited_by, email, role, token ) VALUES ( target_team_id, current_user_id, normalized_email, invited_role, invite_token ) RETURNING * INTO created_invite; RETURN jsonb_build_object( 'id', created_invite.id, 'team_id', created_invite.team_id, 'email', created_invite.email, 'token', created_invite.token, 'role', created_invite.role, 'expires_at', created_invite.expires_at, 'duplicate', false ); END; $$; CREATE OR REPLACE FUNCTION public.accept_team_invite(invite_token text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); current_email text; invite public.team_invites; final_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF invite_token IS NULL OR char_length(invite_token) NOT BETWEEN 20 AND 128 THEN RAISE EXCEPTION 'invalid_invite_token' USING ERRCODE = '22023'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(invite_token, 73043)); SELECT * INTO invite FROM public.team_invites WHERE token = invite_token FOR UPDATE; IF invite.id IS NULL THEN RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002'; END IF; IF invite.accepted_at IS NOT NULL THEN IF invite.accepted_by = current_user_id THEN SELECT role INTO final_role FROM public.team_members WHERE team_id = invite.team_id AND user_id = current_user_id; IF final_role IS NULL THEN RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505'; END IF; RETURN jsonb_build_object( 'team_id', invite.team_id, 'role', final_role, 'duplicate', true, 'already_member', true ); END IF; RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505'; END IF; IF invite.expires_at <= now() THEN RAISE EXCEPTION 'invite_expired' USING ERRCODE = '22023'; END IF; SELECT lower(email) INTO current_email FROM auth.users WHERE id = current_user_id; IF current_email IS NULL OR current_email <> lower(invite.email) THEN RAISE EXCEPTION 'invite_email_mismatch' USING ERRCODE = '42501'; END IF; INSERT INTO public.team_members (team_id, user_id, role) VALUES (invite.team_id, current_user_id, invite.role) ON CONFLICT (team_id, user_id) DO NOTHING; SELECT role INTO final_role FROM public.team_members WHERE team_id = invite.team_id AND user_id = current_user_id FOR UPDATE; IF final_role IS NULL THEN RAISE EXCEPTION 'team_membership_failed'; END IF; UPDATE public.team_invites SET accepted_at = now(), accepted_by = current_user_id WHERE id = invite.id AND accepted_at IS NULL; IF NOT FOUND THEN RAISE EXCEPTION 'invite_acceptance_conflict'; END IF; RETURN jsonb_build_object( 'team_id', invite.team_id, 'role', final_role, 'duplicate', false, 'already_member', false ); END; $$; CREATE OR REPLACE FUNCTION public.update_team_member_role( target_team_id uuid, member_user_id uuid, new_role text ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); caller_role text; prior_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF new_role NOT IN ('admin', 'member') THEN RAISE EXCEPTION 'invalid_team_role' USING ERRCODE = '22023'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73044)); SELECT role INTO caller_role FROM public.team_members WHERE team_id = target_team_id AND user_id = current_user_id; IF caller_role <> 'owner' THEN RAISE EXCEPTION 'team_owner_required' USING ERRCODE = '42501'; END IF; SELECT role INTO prior_role FROM public.team_members WHERE team_id = target_team_id AND user_id = member_user_id FOR UPDATE; IF prior_role IS NULL THEN RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002'; END IF; IF prior_role = 'owner' OR member_user_id = current_user_id THEN RAISE EXCEPTION 'team_owner_immutable' USING ERRCODE = '42501'; END IF; UPDATE public.team_members SET role = new_role WHERE team_id = target_team_id AND user_id = member_user_id; RETURN jsonb_build_object( 'team_id', target_team_id, 'user_id', member_user_id, 'role', new_role ); END; $$; CREATE OR REPLACE FUNCTION public.remove_team_member( target_team_id uuid, member_user_id uuid ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); caller_role text; target_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73045)); SELECT role INTO caller_role FROM public.team_members WHERE team_id = target_team_id AND user_id = current_user_id; SELECT role INTO target_role FROM public.team_members WHERE team_id = target_team_id AND user_id = member_user_id FOR UPDATE; IF target_role IS NULL THEN RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002'; END IF; IF target_role = 'owner' THEN RAISE EXCEPTION 'team_owner_cannot_leave' USING ERRCODE = '42501'; END IF; IF member_user_id <> current_user_id AND ( caller_role NOT IN ('owner', 'admin') OR (target_role = 'admin' AND caller_role <> 'owner') ) THEN RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501'; END IF; DELETE FROM public.team_members WHERE team_id = target_team_id AND user_id = member_user_id; RETURN jsonb_build_object( 'team_id', target_team_id, 'user_id', member_user_id, 'removed', true ); END; $$; CREATE OR REPLACE FUNCTION public.list_team_members(target_team_id uuid) RETURNS TABLE ( user_id uuid, role text, joined_at timestamptz, display_name text, avatar_url text, is_current boolean ) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT member.user_id, member.role, member.joined_at, profile.name, profile.avatar_url, member.user_id = auth.uid() FROM public.team_members AS member LEFT JOIN public.profiles AS profile ON profile.id = member.user_id WHERE member.team_id = target_team_id AND EXISTS ( SELECT 1 FROM public.team_members AS caller WHERE caller.team_id = target_team_id AND caller.user_id = auth.uid() ) ORDER BY CASE member.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, member.joined_at, member.user_id; $$; CREATE OR REPLACE FUNCTION public.list_team_invites(target_team_id uuid) RETURNS TABLE ( id uuid, email text, role text, expires_at timestamptz, created_at timestamptz ) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT invite.id, invite.email, invite.role, invite.expires_at, invite.created_at FROM public.team_invites AS invite WHERE invite.team_id = target_team_id AND invite.accepted_at IS NULL AND EXISTS ( SELECT 1 FROM public.team_members AS caller WHERE caller.team_id = target_team_id AND caller.user_id = auth.uid() AND caller.role IN ('owner', 'admin') ) ORDER BY invite.created_at DESC, invite.id; $$; CREATE OR REPLACE FUNCTION public.cancel_team_invite(invite_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); invite public.team_invites; caller_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; SELECT * INTO invite FROM public.team_invites WHERE id = invite_id FOR UPDATE; IF invite.id IS NULL OR invite.accepted_at IS NOT NULL THEN RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002'; END IF; SELECT role INTO caller_role FROM public.team_members WHERE team_id = invite.team_id AND user_id = current_user_id; IF invite.invited_by <> current_user_id AND caller_role NOT IN ('owner', 'admin') THEN RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501'; END IF; DELETE FROM public.team_invites WHERE id = invite.id; RETURN jsonb_build_object('id', invite.id, 'cancelled', true); END; $$; REVOKE ALL ON FUNCTION public.create_team(text) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, text) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.accept_team_invite(text) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.update_team_member_role(uuid, uuid, text) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.remove_team_member(uuid, uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.list_team_members(uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.list_team_invites(uuid) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.cancel_team_invite(uuid) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.create_team(text) TO authenticated; GRANT EXECUTE ON FUNCTION public.create_team_invite(uuid, text, text) TO authenticated; GRANT EXECUTE ON FUNCTION public.accept_team_invite(text) TO authenticated; GRANT EXECUTE ON FUNCTION public.update_team_member_role(uuid, uuid, text) TO authenticated; GRANT EXECUTE ON FUNCTION public.remove_team_member(uuid, uuid) TO authenticated; GRANT EXECUTE ON FUNCTION public.list_team_members(uuid) TO authenticated; GRANT EXECUTE ON FUNCTION public.list_team_invites(uuid) TO authenticated; GRANT EXECUTE ON FUNCTION public.cancel_team_invite(uuid) TO authenticated; -- Replace the legacy Expo-only registration shape with provider-aware, -- device-owned registrations. Old tokens cannot be proven to belong to a bare -- RN installation, so they are invalidated and clients must register again. DELETE FROM public.push_tokens; UPDATE public.devices SET push_token = NULL WHERE push_token IS NOT NULL; DROP INDEX IF EXISTS public.idx_devices_push_token; DROP POLICY IF EXISTS "push_tokens_own" ON public.push_tokens; ALTER TABLE public.push_tokens ADD COLUMN IF NOT EXISTS provider text, ADD COLUMN IF NOT EXISTS last_registered_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE public.push_tokens ALTER COLUMN device_id SET NOT NULL, ALTER COLUMN provider SET NOT NULL, ADD CONSTRAINT push_tokens_provider_check CHECK (provider IN ('fcm', 'apns', 'webpush')); CREATE POLICY "push_tokens_no_direct_access" ON public.push_tokens FOR SELECT USING (false); DROP POLICY IF EXISTS "devices_insert_own" ON public.devices; CREATE POLICY "devices_insert_own" ON public.devices FOR INSERT WITH CHECK ( user_id = auth.uid() AND revoked_at IS NULL AND push_token IS NULL ); CREATE OR REPLACE FUNCTION public.reject_direct_device_push_token() RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$ BEGIN IF NEW.push_token IS DISTINCT FROM OLD.push_token THEN RAISE EXCEPTION 'device_push_token_is_server_managed' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$; CREATE TRIGGER reject_direct_device_push_token_update BEFORE UPDATE OF push_token ON public.devices FOR EACH ROW EXECUTE FUNCTION public.reject_direct_device_push_token(); CREATE OR REPLACE FUNCTION public.register_push_registration( push_device_id uuid, push_provider text, registration_id text ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); owned_device public.devices; existing_owner uuid; registered public.push_tokens; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF push_provider NOT IN ('fcm', 'apns', 'webpush') OR registration_id IS NULL OR char_length(registration_id) NOT BETWEEN 20 AND 4096 OR registration_id ~ '[[:space:]]' THEN RAISE EXCEPTION 'invalid_push_registration' USING ERRCODE = '22023'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(push_device_id::text, 73046)); PERFORM pg_advisory_xact_lock(hashtextextended(registration_id, 73047)); SELECT * INTO owned_device FROM public.devices WHERE id = push_device_id AND user_id = current_user_id FOR UPDATE; IF owned_device.id IS NULL OR owned_device.revoked_at IS NOT NULL THEN RAISE EXCEPTION 'active_device_not_found' USING ERRCODE = 'P0002'; END IF; IF (owned_device.platform = 'android' AND push_provider <> 'fcm') OR (owned_device.platform = 'ios' AND push_provider <> 'apns') OR (owned_device.platform = 'web' AND push_provider <> 'webpush') THEN RAISE EXCEPTION 'push_provider_platform_mismatch' USING ERRCODE = '22023'; END IF; SELECT user_id INTO existing_owner FROM public.push_tokens WHERE token = registration_id FOR UPDATE; IF existing_owner IS NOT NULL AND existing_owner <> current_user_id THEN RAISE EXCEPTION 'push_registration_owned_by_other_user' USING ERRCODE = '23505'; END IF; DELETE FROM public.push_tokens WHERE device_id = push_device_id OR token = registration_id; INSERT INTO public.push_tokens ( user_id, token, platform, device_name, device_id, provider, last_registered_at ) VALUES ( current_user_id, registration_id, owned_device.platform, owned_device.device_name, owned_device.id, push_provider, now() ) RETURNING * INTO registered; RETURN jsonb_build_object( 'device_id', registered.device_id, 'provider', registered.provider, 'registered_at', registered.last_registered_at ); END; $$; CREATE OR REPLACE FUNCTION public.unregister_push_registration(push_device_id uuid) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); removed_count integer; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; DELETE FROM public.push_tokens WHERE device_id = push_device_id AND user_id = current_user_id; GET DIAGNOSTICS removed_count = ROW_COUNT; RETURN jsonb_build_object( 'device_id', push_device_id, 'removed', removed_count > 0 ); END; $$; REVOKE ALL ON FUNCTION public.register_push_registration(uuid, text, text) FROM PUBLIC, anon; REVOKE ALL ON FUNCTION public.unregister_push_registration(uuid) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.register_push_registration(uuid, text, text) TO authenticated; GRANT EXECUTE ON FUNCTION public.unregister_push_registration(uuid) TO authenticated; COMMIT;