Repository: thegreenwebfoundation/admin-portal
Pull request #642: Add domain claim support for #638 
Author: login: mrchrisadams
Created: 2025-03-17T17:04:47Z | Merged: 2025-03-26T15:52:36Z

This PR introduces new _domain claim_ logic where the platform now attempts to verify a domain is associated with a provider by looking for the existence of a domain, and a domain hash derived from a combination of the domain and a secret value already associated with the provider.

This allows for programatically associating domain with a provider by either:

1. adding a special `via` header to the HTTP response headers for requests served at `hosted-website.com`
2. adding a DNS TXT record for the domain being looked up, to associated say... `hosted-website.com` with the provider


The first is intended to cover the case of a managed service provider already registered with the platform wanting to have their platform's sustainability credentials showing up when `hosted-website.com` is looked up, giving a 'green' result, rather than showing up as 'grey'.

The second allows for multiple domains controlled by a single organisation (in our case a hosting provider) to be associated with that provider, without needing to create carbon.txt files on every single domain.
Auto generated summary below:


---

This pull request introduces several changes across multiple files to enhance domain claim functionality, improve error handling, and add new tests. The most important changes include the addition of domain claim verification methods, new serializers, and related tests.

### Enhancements to domain claim functionality:

* [`apps/greencheck/api/views.py`](diffhunk://#diff-1509052d177b4feeced011bc5b0c2a26c9837bf7f33abfb09beb26f6b2272640L11-L20): Added `DomainClaimSerializer` and updated `DomainClaimView` to include a new `post` method for domain claims. [[1]](diffhunk://#diff-1509052d177b4feeced011bc5b0c2a26c9837bf7f33abfb09beb26f6b2272640L11-L20) [[2]](diffhunk://#diff-1509052d177b4feeced011bc5b0c2a26c9837bf7f33abfb09beb26f6b2272640L198-R224)
* [`apps/greencheck/models/checks.py`](diffhunk://#diff-61e01f39d103b1d71d9615b541b86bca72945c9f7c8d923aa450ac81a518eb64R589-R635): Added `claim_via_carbon_txt` method to `GreenDomain` for domain claim verification using carbon.txt.

### Improved error handling:

* [`apps/greencheck/exceptions.py`](diffhunk://#diff-5e10846abdb1db3ff4e8f3a740ce11d7a2b76df3267d94e334e573713b62ae60R16-R22): Added `NoMatchingDomainHash` exception for handling cases where no matching domain hash is found.
* [`apps/greencheck/api/views.py`](diffhunk://#diff-1509052d177b4feeced011bc5b0c2a26c9837bf7f33abfb09beb26f6b2272640L171-R174): Updated error handling in `post` method to raise `BadRequest` when a domain is not provided.

### New serializers:

* [`apps/greencheck/serializers.py`](diffhunk://#diff-ff853e2ba7b1ce8a3750b222ccc020e7b29b08ff79a22f443fc1a250e9af83bbR248-R282): Added `DomainClaimSerializer` to handle domain claim responses.

### New tests:

* [`apps/greencheck/tests/test_domain_claim.py`](diffhunk://#diff-11f454e7c29e132a3e477bf035ba074c1d4113665114506a2d9563549bd7b0ecR1-R120): Added tests for domain claim functionality, including cases with no domain hash, via header, DNS TXT record, and competing providers.

### Other changes:

* [`apps/greencheck/domain_check.py`](diffhunk://#diff-962c7da672ccda34a8739839ced1c68a0a9123743e584e2af98cbcc9a6c30154R398-R474): Added methods for verifying domain hash via DNS and HTTP headers.


Diff:
diff --git a/apps/greencheck/api/views.py b/apps/greencheck/api/views.py
index cc274930..533a5a98 100644
--- a/apps/greencheck/api/views.py
+++ b/apps/greencheck/api/views.py
@@ -8,16 +8,16 @@
 from rest_framework.authentication import BasicAuthentication, SessionAuthentication
 from rest_framework.response import Response
 
-from apps.accounts.models import Hostingprovider, DomainHash
-from apps.greencheck.models.checks import CO2Intensity
+from apps.accounts.models import DomainHash, Hostingprovider
+from apps.greencheck.models.checks import CO2Intensity, GreenDomain
 
 from ..serializers import (
     CO2IntensitySerializer,
-    ProviderSharedSecretSerializer,
+    DomainClaimSerializer,
     DomainHashSerializer,
+    ProviderSharedSecretSerializer,
 )
 from . import exceptions
-from .permissions import UserManagesHostingProvider
 
 logger = logging.getLogger(__name__)
 
@@ -168,7 +168,10 @@ def post(self, request, format=None):
         # Get the domain from the request data
         domain = request.data.get("domain")
         if not domain:
-            raise exceptions.MissingDomain
+            # Raise a bad request - we need a domain at the very least
+            raise exceptions.BadRequest(
+                "A domain must be provided request a domain hash for it."
+            )
 
         # Check if a domain hash already exists for the given provider and domain
         provider_domain_hash_matches = DomainHash.objects.filter(
@@ -195,4 +198,27 @@ class DomainClaimView(views.APIView):
     a hosting provider can claim a domain.
     """
 
-    permission_classes = [permissions.IsAuthenticated, UserManagesHostingProvider]
+    permission_classes = [permissions.IsAuthenticated]
+    serializer_class = DomainClaimSerializer
+
+    @swagger_auto_schema(tags=["Domain Claim"])
+    def post(self, request, format=None):
+        domain = request.data.get("domain")
+
+        provider_id = request.data.get("provider")
+
+        provider = Hostingprovider.objects.get(id=provider_id)
+
+        # try to claim the domain, and raise the exception if not
+        result = GreenDomain.claim_via_carbon_txt(domain, provider)
+
+        # our serializer serves the 'claimed' result if we have
+        # GreenDomain entry, otherwise 'unclaimed'
+        if not result:
+            # for historical reasons, the domain is listed as url in
+            # the GreenDomain model
+            serialized = DomainClaimSerializer(GreenDomain(url=domain))
+        else:
+            serialized = DomainClaimSerializer(result)
+
+        return Response(serialized.data)
diff --git a/apps/greencheck/domain_check.py b/apps/greencheck/domain_check.py
index 88f1c711..918849d3 100644
--- a/apps/greencheck/domain_check.py
+++ b/apps/greencheck/domain_check.py
@@ -18,17 +18,20 @@
 import socket
 import typing
 import urllib
+from urllib.parse import urlparse
 
+import dns.resolver
+import httpx
 import ipwhois
 import tld
 from django.utils import timezone
 from ipwhois.asn import IPASN
 from ipwhois.exceptions import (
-    IPDefinedError,
+    ASNLookupError,
+    ASNOriginLookupError,
     ASNParseError,
     ASNRegistryError,
-    ASNOriginLookupError,
-    ASNLookupError,
+    IPDefinedError,
 )
 from ipwhois.net import Net
 
@@ -392,3 +395,80 @@ def order_ip_range_by_size(self, ip_matches):
 
         # sort to return the smallest first
         return [obj["ip_range"] for obj in ascending_ip_ranges]
+
+    def verify_domain_hash(self, domain: str, domain_hash: str):
+        # try a DNS lookup first
+        if fetched_hash := self._lookup_domain_hash_with_dns(domain):
+            carbon_txt_url, hash, *rest = fetched_hash.split(" ")
+            if hash == domain_hash:
+                return True
+
+        # then try a via header lookup
+        if fetched_hash := self._lookup_domain_hash_with_via_header(domain):
+            carbon_txt_url, hash, *rest = fetched_hash.split(" ")
+            if hash == domain_hash:
+                return True
+
+        # otherwise if we have no matching domain hash, we return our False result
+        return False
+
+    def _lookup_domain_hash_with_via_header(self, domain):
+        """
+        Send a request to the domain provided, looking for a carbon.txt file in the
+        default well-known locations. Return the domain_hash if found.
+        """
+
+        default_paths = ["/carbon.txt", "/.well-known/carbon.txt"]
+
+        for url_path in default_paths:
+            uri = urlparse(f"https://{domain}{url_path}")
+            try:
+                response = httpx.head(uri.geturl())
+
+                if "via" in response.headers:
+                    via_header = response.headers.get("via")
+                    # exit the function as soon as we see our first valid via header
+                    if "carbon.txt" in via_header:
+                        return via_header
+            except httpx.TimeoutException as ex:
+                logger.warning(f"Timeout fetching: {uri.geturl()}. Error was: {ex}")
+            except httpx.ConnectError as ex:
+                logger.warning(
+                    f"Connection error fetching: {uri.geturl()}. Error was: {ex}"
+                )
+            # lay down the 'tarp' for any other errors
+            except Exception as ex:
+                logger.exception(
+                    f"Unexpected error fetching: {uri.geturl()}. Error was: {ex}"
+                )
+
+        return False
+
+    def _lookup_domain_hash_with_dns(self, domain):
+        # look for a TXT record on the domain first
+        # if there is a valid TXT record on it, return
+        # the hash and delegated url
+        try:
+            answers = dns.resolver.resolve(domain, "TXT")
+
+            for answer in answers:
+                txt_record = answer.to_text().strip('"')
+                if txt_record.startswith("carbon-txt"):
+                    # pull our the value from the TXT record, i.e.
+                    # the bit after `carbon-txt=`:
+                    # carbon-txt="<PATH_TO_CARBON_TXT_FILE> <HASH>"
+                    _, txt_record_body = txt_record.split("=")
+
+                    if txt_record_body:
+                        return txt_record_body
+        except dns.resolver.NoAnswer:
+            logger.info("No result from TXT lookup")
+            return False
+        except dns.resolver.NXDOMAIN as ex:
+            logger.info(f"No result from TXT lookup: {ex.msg}")
+            return False
+        except Exception as ex:
+            logger.exception(f"New exception: {ex}")
+            return False
+
+        return False
diff --git a/apps/greencheck/exceptions.py b/apps/greencheck/exceptions.py
index 82947eff..fe198938 100644
--- a/apps/greencheck/exceptions.py
+++ b/apps/greencheck/exceptions.py
@@ -13,3 +13,10 @@ class NoSharedSecret(NotFound):
 
 class CarbonTxtFileNotFound(Exception):
     pass
+
+
+class NoMatchingDomainHash(NotFound):
+    """
+    An exception raised when we try to fetch a shared secret for a provider
+    but no shared secret has been set.
+    """
diff --git a/apps/greencheck/models/checks.py b/apps/greencheck/models/checks.py
index 6f11400c..f179c81c 100644
--- a/apps/greencheck/models/checks.py
+++ b/apps/greencheck/models/checks.py
@@ -15,10 +15,12 @@
 from django_mysql import models as dj_mysql_models
 from django_mysql import models as mysql_models
 from model_utils import models as mu_models
+
 from apps.greencheck.validators import validate_ip_range
 
 from ...accounts import models as ac_models
 from .. import choices as gc_choices
+from ..exceptions import NoMatchingDomainHash
 
 logger = logging.getLogger(__name__)
 
@@ -197,7 +199,7 @@ def ip_range_length(self) -> int:
 
     def archive(self) -> "GreencheckIp":
         """
-        Mark a GreencheckIp as inactive, as a softer alternative to deletion, 
+        Mark a GreencheckIp as inactive, as a softer alternative to deletion,
         returning the Greencheck IP for further processing.
         """
         self.active = False
@@ -206,7 +208,7 @@ def archive(self) -> "GreencheckIp":
 
     def unarchive(self) -> "GreencheckIp":
         """
-        Mark a GreencheckIp as inactive, as a softer alternative to deletion, 
+        Mark a GreencheckIp as inactive, as a softer alternative to deletion,
         returning the Greencheck IP for further processing.
         """
         self.active = True
@@ -398,7 +400,7 @@ class GreencheckASN(mu_models.TimeStampedModel):
 
     def archive(self) -> "GreencheckASN":
         """
-        Mark a GreencheckASN as inactive, as a softer alternative to deletion, 
+        Mark a GreencheckASN as inactive, as a softer alternative to deletion,
         returning the Greencheck ASN for further processing.
         """
         self.active = False
@@ -407,7 +409,7 @@ def archive(self) -> "GreencheckASN":
 
     def unarchive(self) -> "GreencheckASN":
         """
-        Mark a GreencheckASN as inactive, as a softer alternative to deletion, 
+        Mark a GreencheckASN as inactive, as a softer alternative to deletion,
         returning the Greencheck ASN for further processing.
         """
         self.active = True
@@ -500,6 +502,10 @@ class GreenDomain(models.Model):
     green = models.BooleanField()
     modified = models.DateTimeField()
 
+    # TODO consider updating Green Domain to support creation date
+    # like so. this would allow us to set a TTL on them and so on
+    # created_at = models.DateTimeField(blank=True, null=True)
+
     def __str__(self):
         return f"{self.url} - {self.modified}"
 
@@ -510,6 +516,7 @@ def create_for_provider(cls, domain: str, provider: ac_models.Hostingprovider):
         Create a new green domain for the domain passed in,  and allocate
         it  to the given provider.
         """
+
         dom = GreenDomain(
             green=True,
             url=domain,
@@ -565,7 +572,7 @@ def from_sitecheck(cls, sitecheck):
             )
         except ac_models.Hostingprovider.DoesNotExist:
             logger.warning(
-                ("We expected to find a provider for this sitecheck, " "But didn't. ")
+                ("We expected to find a provider for this sitecheck, But didn't. ")
             )
             return cls.grey_result(domain=sitecheck.url)
 
@@ -579,6 +586,53 @@ def from_sitecheck(cls, sitecheck):
             green=True,
         )
 
+    @classmethod
+    def claim_via_carbon_txt(cls, domain, provider: ac_models.Hostingprovider):
+        """
+        Accept a domain, and try verifying it with the carbon.txt validator.
+
+        We use the carbon.txt validator library to look up the domain, and
+        if there is the matching
+        domain we were expecting, we create the domain as "green domain" associated
+        with the provider that initially requested the domain hash.
+        """
+
+        # first of all, if there is no domain hash for the domain exit early - there is no
+        # point in trying to claim a domain hash for a domain that no provider has requested
+        # a domain hash for.
+        from ..domain_check import GreenDomainChecker
+
+        checker = GreenDomainChecker()
+
+        # we need to filter by domain AND provider, becauase if two providers
+        # both try to claim a domain, we can end up allocating it to the
+        # wrong provider if we don't filter by both
+        domain_hash = ac_models.DomainHash.objects.filter(
+            domain=domain, provider=provider
+        )
+
+        if not domain_hash:
+            raise NoMatchingDomainHash
+
+        # if we have a domain hash for the domain, choose the most recent one,
+        # so we can compare it to what we find on a domain
+        domain_hash = domain_hash.order_by("-created").first()
+
+        matched_hash = checker.verify_domain_hash(domain, domain_hash=domain_hash.hash)
+
+        # We don't want to create duplicate domains for the same provider.
+        # If a domain already exists for the provider, we should return it instead,
+        # of creating a new domain
+        if matched_hash:
+            provider = domain_hash.provider
+
+            if existing_domain := GreenDomain.objects.filter(
+                url=domain, hosted_by_id=provider.id
+            ):
+                return existing_domain.first()
+
+            return cls.create_for_provider(domain, provider)
+
     # Queries
     @property
     def hosting_provider(self) -> typing.Union[ac_models.Hostingprovider, None]:
diff --git a/apps/greencheck/serializers.py b/apps/greencheck/serializers.py
index e39d3fb0..648ac358 100644
--- a/apps/greencheck/serializers.py
+++ b/apps/greencheck/serializers.py
@@ -5,10 +5,10 @@
 from taggit import serializers as tag_serializers
 
 from apps.accounts.models import (
+    DomainHash,
     Hostingprovider,
     HostingProviderSupportingDocument,
     ProviderSharedSecret,
-    DomainHash,
 )
 from apps.greencheck.models.checks import CO2Intensity
 
@@ -235,7 +235,8 @@ class Meta:
 
 
 class DomainHashSerializer(serializers.ModelSerializer):
-    domain_hash = serializers.CharField(source="hash")  # Map 'hash' to 'domain_hash'
+    # Map 'hash' to 'domain_hash'
+    domain_hash = serializers.CharField(source="hash")
 
     class Meta:
         model = DomainHash
@@ -244,3 +245,38 @@ class Meta:
             "domain",
             "created",
         ]
+
+
+class DomainClaimSerializer(serializers.ModelSerializer):
+    """
+    A Domain Claims represents a domain that has been ver
+    """
+
+    domain = serializers.CharField(source="url")
+    provider = serializers.CharField(source="hosted_by")
+    provider_id = serializers.IntegerField(source="hosted_by_id")
+    provider_website = serializers.CharField(source="hosted_by_website")
+    # used to determine the status dynamically based on if a GreeenDomain
+    # object is a 'real' one retrieved from the database. If so it counts
+    # as claimed.
+    status = serializers.SerializerMethodField()
+
+    def get_status(self, obj):
+        """
+        Determine the status of the domain claim.
+        If the object is a valid GreenDomain instance, return 'claimed'.
+        Otherwise, return 'unclaimed'.
+        """
+        return "claimed" if obj.pk else "unclaimed"
+
+    class Meta:
+        model = GreenDomain
+        fields = [
+            "domain",
+            "provider",
+            "provider_id",
+            "provider_website",
+            "status",
+            # TODO serve "created_at" field so people can see
+            # when a domain was created
+        ]
diff --git a/apps/greencheck/tests/test_domain_claim.py b/apps/greencheck/tests/test_domain_claim.py
new file mode 100644
index 00000000..0c3c7f8d
--- /dev/null
+++ b/apps/greencheck/tests/test_domain_claim.py
@@ -0,0 +1,120 @@
+import pytest
+from guardian.shortcuts import assign_perm
+
+from apps.accounts.permissions import manage_provider
+from apps.greencheck.exceptions import NoMatchingDomainHash
+from apps.greencheck.models import GreenDomain
+
+
+@pytest.mark.django_db
+class TestGreenDomainClaim:
+    """
+    This code exercises the "domain claim" functionality in the platform, for
+    carrying out a carbon.txt domain validation.
+    """
+
+    def test_claim_domain_with_no_domain_hash(self):
+        """
+        Test that attempting to claim a domain without a corresponding domain hash
+        raises a NoMatchingDomainHash exception.
+        """
+        domain_name = "example.com"
+
+        with pytest.raises(NoMatchingDomainHash):
+            GreenDomain.claim_via_carbon_txt(domain_name, provider=None)
+
+    def test_claim_domain_with_via_header(self, user_with_provider, httpx_mock):
+        """
+        Test that claiming a domain with a valid domain hash in the 'via header'
+        is successful.
+        """
+
+        managed_service = "managed-service.com"
+        hosted_site = "example.com"
+
+        provider = user_with_provider.hosting_providers.first()
+        provider.refresh_shared_secret()
+
+        domain_hash = provider.create_domain_hash(hosted_site, user_with_provider)
+
+        httpx_mock.add_response(
+            url="https://example.com/carbon.txt",
+            headers={"Via": f"{managed_service}/carbon.txt {domain_hash.hash}"},
+        )
+
+        resp = GreenDomain.claim_via_carbon_txt(hosted_site, provider)
+
+        assert resp in GreenDomain.objects.filter(hosted_by_id=provider.id)
+
+    def test_claim_domain_with_dns_txt_record(self, user_with_provider, mocker):
+        """
+        Test that claiming a domain with a valid domain hash as a TXT record in DNS
+        is successful.
+        """
+
+        # Given: a managed service that operates multiple domains
+        provider = user_with_provider.hosting_providers.first()
+        provider.refresh_shared_secret()
+
+        # Given: a website operated by the managing service above
+        hosted_site = "delegating-with-txt-record.carbontxt.org"
+
+        # And: the website has been associated with the provider
+        domain_hash = provider.create_domain_hash(hosted_site, user_with_provider)
+
+        # And DNS record has been set up for the domain with the correct hash added
+        # as a TXT record, and pointing to the managing service's carbon.txt file
+        mocker.patch(
+            "apps.greencheck.domain_check.GreenDomainChecker._lookup_domain_hash_with_dns",
+            return_value=f"managed-service.com/carbon.txt {domain_hash.hash}",
+        )
+
+        # When: the managing service posts a request to verify the domain hash
+        # has been added to the domain's DNS record
+        resp = GreenDomain.claim_via_carbon_txt(hosted_site, provider)
+
+        # Then: the domain show up as associated with the managed service provider
+        assert resp in GreenDomain.objects.filter(hosted_by_id=provider.id)
+
+    def test_claim_domain_with_two_competing_providers(
+        self, user_with_provider, hosting_provider_factory, user_factory, mocker
+    ):
+        """
+        We want to make sure that when we have two providers both trying to claim a
+        domain, we allocate the domain to the correct provider, based on the
+        domain hash found, not just the most recent provider to request.
+        """
+
+        # Given: one 'correct' hosting provider
+        correct_provider = user_with_provider.hosting_providers.first()
+        correct_provider.refresh_shared_secret()
+
+        # And: a 'sneaky' provider with a different user
+        sneaky_user = user_factory()
+        sneaky_provider = hosting_provider_factory(created_by=sneaky_user)
+        sneaky_provider.refresh_shared_secret()
+        assign_perm(manage_provider.codename, sneaky_user, sneaky_provider)
+
+        # And: a website that both providers want to claim
+        hosted_site = "site-with-competing-claims.example.com"
+
+        # And: both providers created domain hashes for the site
+        correct_domain_hash = correct_provider.create_domain_hash(
+            hosted_site, user_with_provider
+        )
+        domain_hash_2 = sneaky_provider.create_domain_hash(hosted_site, sneaky_user)
+
+        # When: the DNS record contains correct_provider's domain_hash
+        mocker.patch(
+            "apps.greencheck.domain_check.GreenDomainChecker._lookup_domain_hash_with_dns",
+            return_value=f"correct_provider.example.com/carbon.txt {correct_domain_hash.hash}",
+        )
+
+        # Then: even when both providers try to claim the domain
+        resp = GreenDomain.claim_via_carbon_txt(hosted_site, correct_provider)
+        resp2 = GreenDomain.claim_via_carbon_txt(hosted_site, sneaky_provider)
+
+        # The domain should be associated with just the correct provider,
+        # as its hash was found
+        assert resp in GreenDomain.objects.filter(hosted_by_id=correct_provider.id)
+        assert resp not in GreenDomain.objects.filter(hosted_by_id=sneaky_provider.id)
diff --git a/apps/greencheck/tests/views/test_api_domain_claim.py b/apps/greencheck/tests/views/test_api_domain_claim.py
new file mode 100644
index 00000000..1ea06071
--- /dev/null
+++ b/apps/greencheck/tests/views/test_api_domain_claim.py
@@ -0,0 +1,106 @@
+import pytest
+
+from apps.greencheck.models import GreenDomain
+
+
+class TestDomainclaimForProvider:
+    """
+    Tests for the domain claim API endpoint, to allow a domain to be 'claimed'
+    by a Provider that has already created a domain hash.
+    """
+
+    @pytest.mark.django_db
+    def test_successful_domain_claim_by_provider(
+        self, client, user_with_provider, mocker
+    ):
+        """
+        The happy path for claiming domain once a domain hash has
+        been created by a given provider.
+        """
+        # Given: A logged-in user associated with a hosting provider
+        client.force_login(user_with_provider)
+        provider = user_with_provider.hosting_providers.first()
+        provider.refresh_shared_secret()
+        domain = "example.com"
+
+        domain_hash = provider.create_domain_hash(domain, user_with_provider)
+
+        payload = {"domain": domain, "provider": provider.id}
+
+        mocker.patch(
+            "apps.greencheck.domain_check.GreenDomainChecker._lookup_domain_hash_with_dns",
+            return_value=f"managed-service.com/carbon.txt {domain_hash.hash}",
+        )
+
+        # When: The user sends a POST request to /api/v3/domain_claim/ for the domain
+        response = client.post(
+            "/api/v3/domain_claim/", payload, content_type="application/json"
+        )
+
+        # Then: The response should contain a domain, provider and meaningful status
+        assert response.status_code == 200
+        response_data = response.json()
+        assert response_data["domain"] == domain
+        assert response_data["provider"] == provider.name
+        assert response_data["provider_id"] == provider.id
+        assert response_data["provider_website"] == provider.website
+        assert response_data["status"] == "claimed"
+
+    @pytest.mark.django_db
+    def test_domain_claim_for_an_already_claimed_domain_by_a_provider(
+        self, client, user_with_provider, mocker
+    ):
+        """
+        If a domain already exists for that provider it should not be claimed again.
+        """
+        # Given: A logged-in user associated with a hosting provider
+        client.force_login(user_with_provider)
+        provider = user_with_provider.hosting_providers.first()
+        provider.refresh_shared_secret()
+        domain = "example.com"
+
+        domain_hash = provider.create_domain_hash(domain, user_with_provider)
+
+        payload = {"domain": domain}
+
+        mocker.patch(
+            "apps.greencheck.domain_check.GreenDomainChecker._lookup_domain_hash_with_dns",
+            return_value=f"managed-service.com/carbon.txt {domain_hash.hash}",
+        )
+
+        # When: The user sends a POST request to /api/v3/domain_claim/ for the domain
+        first_response = client.post(
+            "/api/v3/domain_claim/", payload, content_type="application/json"
+        )
+        # And: a second request is made for the same domain by the same provider
+        repeat_response = client.post(
+            "/api/v3/domain_claim/", payload, content_type="application/json"
+        )
+
+        # Then: Both responses should contain a domain, provider and meaningful status
+        for resp in [first_response, repeat_response]:
+            assert resp.status_code == 200
+            resp_data = resp.json()
+            assert resp_data["domain"] == domain
+            assert resp_data["provider"] == provider.name
+            assert resp_data["provider_id"] == provider.id
+            assert resp_data["provider_website"] == provider.website
+            assert resp_data["status"] == "claimed"
+
+        # And: we should not have any duplicate domains created in our table
+        assert GreenDomain.objects.filter(hosted_by_id=provider.id).count() == 1
+
+    @pytest.mark.skip(reason="Not implemented")
+    @pytest.mark.django_db
+    def test_domain_claim_for_an_already_claimed_domain_by_a_provider(
+        self, client, user_with_provider, mocker
+    ):
+        """
+        When a different provider is able to demonstrate control over a domain
+        we reallocate the domain to them.
+        """
+
+        # Ideally this would rely on us being able to order green domains by
+        # created date. That would leave us with an audit trail of claimed domains
+        # rather than overwiting them with the most recent claim.
+        pass
diff --git a/greenweb/urls.py b/greenweb/urls.py
index df653264..5154cf40 100644
--- a/greenweb/urls.py
+++ b/greenweb/urls.py
@@ -119,11 +119,11 @@
         api_views.DomainHashView.as_view(),
         name="carbon-txt-domain-hash",
     ),
-    # path(
-    #     "api/v3/domain_claim/",
-    #     api_views.DomainClaimView.as_view(),
-    #     name="carbon-txt-shared-secret",
-    # ),
+    path(
+        "api/v3/domain_claim/",
+        api_views.DomainClaimView.as_view(),
+        name="carbon-txt-domain-claim",
+    ),
     path(
         "api/v3/greencheckimage/<url>",
         image_views.greencheck_image,
diff --git a/pyproject.toml b/pyproject.toml
index 9afb4009..f587201d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -70,6 +70,8 @@ dependencies = [
     "django-guardian>=2.4.0",
     "drf-api-logger>=1.1.16",
     "urllib3>=2.2.3",
+    "httpx>=0.28.1",
+    "pytest-httpx>=0.35.0",
 ]
 
 [tool.uv]
diff --git a/uv.lock b/uv.lock
index c7965c34..45a5750e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -55,12 +55,14 @@ dependencies = [
     { name = "freezegun" },
     { name = "geoip2" },
     { name = "gunicorn" },
+    { name = "httpx" },
     { name = "ipwhois" },
     { name = "iso3166" },
     { name = "markdown" },
     { name = "mysqlclient" },
     { name = "phpserialize" },
     { name = "pillow" },
+    { name = "pytest-httpx" },
     { name = "python-dateutil" },
     { name = "python-dotenv" },
     { name = "pyyaml" },
@@ -156,12 +158,14 @@ requires-dist = [
     { name = "freezegun", specifier = ">=1.5.1" },
     { name = "geoip2", specifier = ">=4.8.0" },
     { name = "gunicorn", specifier = ">=23.0.0" },
+    { name = "httpx", specifier = ">=0.28.1" },
     { name = "ipwhois", specifier = ">=1.3.0" },
     { name = "iso3166", specifier = ">=2.1.1" },
     { name = "markdown", specifier = ">=3.7" },
     { name = "mysqlclient", specifier = "==2.1.1" },
     { name = "phpserialize", specifier = ">=1.3" },
     { name = "pillow", specifier = ">=11.0.0" },
+    { name = "pytest-httpx", specifier = ">=0.35.0" },
     { name = "python-dateutil", specifier = ">=2.9.0.post0" },
     { name = "python-dotenv", specifier = ">=1.0.1" },
     { name = "pyyaml", specifier = ">=6.0.2" },
@@ -1678,6 +1682,34 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
 ]
 
+[[package]]
+name = "httpcore"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
 [[package]]
 name = "hypothesis"
 version = "6.116.0"
@@ -2605,6 +2637,19 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/c7/56/d3ef25286dc8df9d1da0b325ee4b1b1ffd9736e44f9b30cfbe464e9f4f14/pytest_factoryboy-2.7.0-py3-none-any.whl", hash = "sha256:bf3222db22d954fbf46f4bff902a0a8d82f3fc3594a47c04bbdc0546ff4c59a6", size = 16268 },
 ]
 
+[[package]]
+name = "pytest-httpx"
+version = "0.35.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "httpx" },
+    { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442 },
+]
+
 [[package]]
 name = "pytest-mock"
 version = "3.14.0"
@@ -2828,6 +2873,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012 },
     { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352 },
     { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344 },
+    { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498 },
     { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205 },
     { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185 },
     { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 },
@@ -2836,6 +2882,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 },
     { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 },
     { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 },
+    { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 },
     { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 },
     { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 },
     { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011 },
@@ -2844,6 +2891,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785 },
     { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017 },
     { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270 },
+    { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059 },
     { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583 },
     { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 },
 ]


Reviews:
user: login: timcowlishaw
body: As discussed:

- We want to continue to pass the user requesting the domain hash in on hash creation, for the purposes of the audit trail,
- On verifying the domain hash, we want to filter by the *provider* for which we are verifying the domain, to avoid a situation where multiple people attempt to verify the domain at once, and all (or the first to complete the process, regardless of ownership) are succesful.

state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T11:21:26Z

Review comments:
diff_hunk: @@ -579,6 +585,49 @@ def from_sitecheck(cls, sitecheck):
             green=True,
         )
 
+    @classmethod
+    def claim_via_carbon_txt(cls, domain):
+        """
+        Accept a domain, and try verifying it with the carbon.txt validator.
+
+        We use the carbon.txt validator library to look up the domain, and
+        if there is the matching
+        domain we were expecting, we create the domain as "green domain" associated
+        with the provider that initially requested the domain hash.
+        """
+
+        # first of all, if there is no domain hash for the domain exit early - there is no
+        # point in trying to claim a domain hash for a domain that no provider has requested
+        # a domain hash for.
+        from ..domain_check import GreenDomainChecker
+
+        checker = GreenDomainChecker()
+
+        domain_hash = ac_models.DomainHash.objects.filter(domain=domain)
+        from ..exceptions import NoMatchingDomainHash
+
+        if not domain_hash:
+            raise NoMatchingDomainHash
+
+        # if we have a domain hash for the domain, choose the most recent one, so we can compare it to
+        # what we find on a domain
+        domain_hash = domain_hash.order_by("-created").first()
+
+        matched_hash = checker.verify_domain_hash(domain, domain_hash=domain_hash.hash)
+
+        # We don't want to create duplicate domains for the same provider.
+        # If a domain already exists for the provider, we should return it instead,
+        # of creating a new domain
+        if matched_hash:
+            provider = domain_hash.provider
+
+            if existing_domain := GreenDomain.objects.filter(
path: apps/greencheck/models/checks.py
user: login: timcowlishaw
body: As discussed, we'll need to filter by the provider who is asserting ownership here, to stop "looking over the shoulder" attacks!
created_at: 2025-03-25T11:10:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,81 @@
+import pytest
+
+from apps.greencheck.exceptions import NoMatchingDomainHash
+from apps.greencheck.models import GreenDomain
+
+
+class TestGreenDomainClaim:
+    """
+    This code exercises the "domain claim" functionality in the platform, for
+    carrying out a carbon.txt domain validation.
+    """
+
+    @pytest.mark.django_db
+    class TestGreenDomainClaim:
+        """
+        This code exercises the "domain claim" functionality in the platform, for
+        carrying out a carbon.txt domain validation.
+        """
+
+        def test_claim_domain_with_no_domain_hash(self):
+            """
+            Test that attempting to claim a domain without a corresponding domain hash
+            raises a NoMatchingDomainHash exception.
+            """
+            domain_name = "example.com"
+
+            with pytest.raises(NoMatchingDomainHash):
+                GreenDomain.claim_via_carbon_txt(domain_name)
+
+        def test_claim_domain_with_via_header(self, user_with_provider, httpx_mock):
+            """
+            Test that claiming a domain with a valid domain hash in the 'via header'
+            is successful.
+            """
+
+            managed_service = "managed-service.com"
+            hosted_site = "example.com"
+
+            provider = user_with_provider.hosting_providers.first()
+            provider.refresh_shared_secret()
+
+            domain_hash = provider.create_domain_hash(hosted_site, user_with_provider)
+
+            httpx_mock.add_response(
+                url="https://example.com/carbon.txt",
+                headers={"Via": f"{managed_service}/carbon.txt {domain_hash.hash}"},
+            )
+
+            resp = GreenDomain.claim_via_carbon_txt(hosted_site)
path: apps/greencheck/tests/test_domain_claim.py
user: login: timcowlishaw
body: The provider will need to be passed in here (as discussed on the call)
created_at: 2025-03-25T11:11:18Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-17T17:06:58Z
body: <details><summary>Old Energy Estimation</summary>





















Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.9669|401.239|4.05|99|
|checkout|8.49|3.57679|1.79|2|
|pip install uv wheel|8.59|3.57902|1.79|2|
|pip install requirements|66.4|31.7275|5.29|6|
|pytest|28.3826|362.355|4.12|88|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-17T17:07:05Z
body: <details><summary>Old Energy Estimation</summary>



















Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|30.2904|417.939|4.06|103|
|checkout|9.28|3.60289|3.60|1|
|pip install uv wheel|9.4|3.60647|1.80|2|
|pip install requirements|67.998|32.2488|5.37|6|
|pytest|28.2183|378.481|4.11|92|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T08:51:57Z
body: <details><summary>Old Energy Estimation</summary>

















Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.9395|422.238|4.06|104|
|checkout|16.89|3.85425|1.93|2|
|pip install uv wheel|16.95|3.85582|1.93|2|
|pip install requirements|68.318|32.1541|5.36|6|
|pytest|28.1169|382.373|4.11|93|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T08:52:09Z
body: <details><summary>Old Energy Estimation</summary>















Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.3357|428.601|4.04|106|
|checkout|13.16|3.74392|1.87|2|
|pip install uv wheel|13.2|3.74001|1.87|2|
|pip install requirements|69.512|32.7492|6.55|5|
|pytest|27.6214|388.368|4.13|94|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T08:57:12Z
body: <details><summary>Old Energy Estimation</summary>













Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|30.4927|445.28|4.09|109|
|checkout|18.47|3.89605|1.95|2|
|pip install uv wheel|26.6567|12.4478|4.15|3|
|pip install requirements|68.706|32.3893|5.40|6|
|pytest|28.4949|396.547|4.17|95|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T08:57:17Z
body: <details><summary>Old Energy Estimation</summary>











Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|30.1453|447.517|4.11|109|
|checkout|8.94|3.59259|3.59|1|
|pip install uv wheel|23.425|16.149|4.04|4|
|pip install requirements|64.818|31.4623|5.24|6|
|pytest|28.4507|396.314|4.13|96|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: mrchrisadams
created_at: 2025-03-18T11:54:12Z
body: I had made changes to the hashes here to make them a tad easier to use by introducing ways to identify a greenweb domain hash and the version it uses.

It might make sense to take them out and put them into a separate PR, but it would be good to revisit in future, as the current domain hash is pretty opaque, and I think a few small changes would provide some helpful affordances in future for identifying _domain hashes_ as opposed to just regular SHA 256 ones
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-26T12:19:59Z
body: <details><summary>Old Energy Estimation</summary>









Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.6194|415.66|4.04|103|
|checkout|21.17|3.97085|1.99|2|
|pip install uv wheel|21.11|3.96702|1.98|2|
|pip install requirements|66.55|31.5331|5.26|6|
|pytest|27.8051|376.189|4.09|92|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T12:20:10Z
body: <details><summary>Old Energy Estimation</summary>





Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.1041|435.822|4.04|108|
|checkout|11.1|3.66817|3.67|1|
|pip install uv wheel|11.17|3.66915|1.83|2|
|pip install requirements|65.528|31.8328|5.31|6|
|pytest|27.6193|396.651|4.09|97|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T15:19:28Z
body: <details><summary>Old Energy Estimation</summary>



Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|30.3392|414.151|4.10|101|
|checkout|21.87|3.98799|1.99|2|
|pip install uv wheel|21.87|3.98479|1.99|2|
|pip install requirements|70.964|32.8807|5.48|6|
|pytest|28.2191|373.297|4.15|90|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T15:19:32Z
body: <details><summary>Old Energy Estimation</summary>



Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|30.1097|430.273|4.06|106|
|checkout|14.44|3.78665|3.79|1|
|pip install uv wheel|14.53|3.78594|1.89|2|
|pip install requirements|70.602|33.0932|5.52|6|
|pytest|27.764|389.608|4.10|95|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T15:51:06Z
body: Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.8443|417.771|4.10|102|
|checkout|14.24|3.77837|3.78|1|
|pip install uv wheel|14.33|3.77976|1.89|2|
|pip install requirements|65.972|31.6583|6.33|5|
|pytest|28.2121|378.555|4.16|91|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-26T15:51:14Z
body: Eco-CI Output: 
|Label|🖥 avg. CPU utilization [%]|🔋 Total Energy [Joules]|🔌 avg. Power [Watts]|Duration [Seconds]|
|---|---|---|---|---|
|Total Run (incl. overhead)|29.183|436.653|4.08|107|
|checkout|13.775|7.46531|3.73|2|
|pip install uv wheel|5.84|3.48191|1.74|2|
|pip install requirements|64.406|31.5296|6.31|5|
|pytest|27.8796|394.176|4.15|95|

❌ CO2 Data:
Error in retrieving values. Please see the detailed logs for the exact error messages!

author_association: NONE
user: login: mrchrisadams
created_at: 2025-03-26T15:53:30Z
body: I've merged this based on the synchronous code review @timcowlishaw and I did yesterday, updating the design based on our convo.
author_association: MEMBER