diff --git a/README.md b/README.md
index 17b9c64..30f9ffd 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ modules:
         federation_localization_url: "https://localhost:8080/localization", # Full url where to fetch the federation localization from, required. Should be the same host as federation list.
         federation_list_client_cert: "tests/certs/client.pem", # path to a pem encoded client certificate for mtls, required if federation list url is https
         gematik_ca_baseurl: "https://download-ref.tsl.ti-dienste.de/", # the baseurl to the ca to use for the federation list, required
+        tim-type: "epa" or "pro", # Patient/Insurance or Professional mode, defaults to "pro" mode. Optional currently, but will be required in a later release
 ```

 ## Testing
diff --git a/synapse_invite_checker/config.py b/synapse_invite_checker/config.py
index 970ec22..bb3dc27 100644
--- a/synapse_invite_checker/config.py
+++ b/synapse_invite_checker/config.py
@@ -14,6 +14,8 @@
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
 from dataclasses import dataclass

+from synapse_invite_checker.types import TimType
+

 @dataclass
 class InviteCheckerConfig:
@@ -24,3 +26,4 @@ class InviteCheckerConfig:
     federation_list_client_cert: str = ""
     federation_localization_url: str = ""
     gematik_ca_baseurl: str = ""
+    tim_type: TimType = TimType.PRO
diff --git a/synapse_invite_checker/invite_checker.py b/synapse_invite_checker/invite_checker.py
index 5762758..055228d 100644
--- a/synapse_invite_checker/invite_checker.py
+++ b/synapse_invite_checker/invite_checker.py
@@ -17,7 +17,7 @@
 import logging
 import time
 from contextlib import suppress
-from typing import Literal
+from typing import Any, Callable, Dict, Literal, cast
 from urllib.parse import quote, urlparse

 from cachetools import TTLCache, keys
@@ -32,6 +32,7 @@
     load_certificate,
 )
 from synapse.api.constants import AccountDataTypes
+from synapse.config import ConfigError
 from synapse.http.client import BaseHttpClient
 from synapse.http.proxyagent import ProxyAgent
 from synapse.http.server import JsonResource
@@ -39,6 +40,7 @@
 from synapse.server import HomeServer
 from synapse.storage.database import make_conn
 from synapse.types import UserID
+from twisted.internet.defer import Deferred
 from twisted.internet.ssl import PrivateCertificate, optionsForClientTLS, platformTrust
 from twisted.web.client import HTTPConnectionPool
 from twisted.web.iweb import IPolicyForHTTPS
@@ -50,10 +52,14 @@
     ContactResource,
     ContactsResource,
 )
-from synapse_invite_checker.rest.messenger_info import MessengerInfoResource
+from synapse_invite_checker.rest.messenger_info import (
+    MessengerInfoResource,
+    MessengerIsInsuranceResource,
+)

 from synapse_invite_checker.store import InviteCheckerStore
-from synapse_invite_checker.types import FederationList
+from synapse_invite_checker.types import FederationList, TimType
+

 logger = logging.getLogger(__name__)

@@ -179,14 +185,20 @@ def __init__(self, config: InviteCheckerConfig, api: ModuleApi):

         # The TiMessengerInformation API resource
         MessengerInfoResource(self.config).register(self.resource)
+        MessengerIsInsuranceResource(self.config, self.is_domain_insurance).register(
+            self.resource
+        )

         # Register everything at the root of our namespace/app, to avoid complicating
         # Synapse's regex http registration
         self.api.register_web_resource(BASE_API_PREFIX, self.resource)
+
+        self.api._hs._reactor.callWhenRunning(self.after_startup)
+
         logger.info("Module initialized at %s", BASE_API_PREFIX)

     @staticmethod
-    def parse_config(config):
+    def parse_config(config: Dict[str, Any]) -> InviteCheckerConfig:
         logger.error("PARSE CONFIG")
         _config = InviteCheckerConfig()

@@ -223,8 +235,41 @@ def parse_config(config):
             msg = "Federation list config requires an mtls (PEM) cert for https connections"
             raise Exception(msg)

+        # Check that the configuration is defined. This allows a grace period for
+        # migration. For now, just issue a warning in the logs. The default of 'pro'
+        # is set inside InviteCheckerConfig
+        _tim_type = config.get("tim-type", "").lower()
+        if not _tim_type:
+            logger.warning(
+                "Please remember to set `tim-type` in your configuration. Defaulting to 'Pro' mode"
+            )
+
+        else:
+            if _tim_type == "epa":
+                _config.tim_type = TimType.EPA
+            elif _tim_type == "pro":
+                _config.tim_type = TimType.PRO
+            else:
+                msg = "`tim-type` setting is not a recognized value. Please fix."
+                raise ConfigError(msg)
+
         return _config

+    def after_startup(self) -> None:
+        """
+        To be called when the rector is running. Validates that the epa setting matches
+        the insurance setting in the federation list.
+        """
+        fetch_deferred = Deferred.fromCoroutine(self._fetch_federation_list())
+        fed_list = cast(FederationList, fetch_deferred.result)
+        if self.config.tim_type == TimType.EPA and not fed_list.is_insurance(
+            self.api._hs.config.server.server_name
+        ):
+            logger.warning(
+                "This server has enabled ePA Mode in its config, but is not found on "
+                "the Federation List as an Insurance Domain!"
+            )
+
     async def _raw_localization_fetch(self, mxid: str) -> str:
         resp = await self.federation_list_client.get_raw(
             self.config.federation_localization_url, {"mxid": mxid}
@@ -296,13 +341,15 @@ def _load_cert_b64(self, cert: str) -> X509:
         return load_certificate(FILETYPE_ASN1, base64.b64decode(cert))

     @cached(cache=TTLCache(maxsize=1, ttl=60 * 60))
-    async def fetch_federation_allow_list(self) -> tuple[set[str], FederationList]:
+    async def _fetch_federation_list(
+        self,
+    ) -> FederationList:
         """
         Fetch the raw data for the federation list, verify it is authentic and parse
         the data into a usable format

-        Returns: a tuple of a set of the domains from the list and the complete
-            validated list(for testing)
+        Returns:
+            a FederationList object

         """
         raw_list = await self._raw_federation_list_fetch()
@@ -336,15 +383,38 @@ async def fetch_federation_allow_list(self) -> tuple[set[str], FederationList]:
             raise Exception(msg)

         # Validate incoming, potentially incomplete or corrupt data
-        fed_list = FederationList.model_validate_json(jws_verify.payload)
-        return {fed.domain for fed in fed_list.domainList}, fed_list
+        return FederationList.model_validate_json(jws_verify.payload)
+
+    async def _domain_list_check(self, check: Callable[[str], bool]) -> bool:
+        """Run a `check` against data found on the FederationList"""
+        fed_list = await self._fetch_federation_list()
+        if check(fed_list):
+            return True
+
+        # Per A_25537:
+        # The domain wasn't found but the list may have changed since the last look.
+        # Re-fetch the list and try again
+        self._fetch_federation_list.cache_clear()
+        fed_list = await self._fetch_federation_list()
+        return check(fed_list)
+
+    async def is_domain_allowed(self, domain: str) -> bool:
+        return await self._domain_list_check(lambda fl: fl.allowed(domain))
+
+    async def is_domain_insurance(self, domain: str) -> bool:
+        return await self._domain_list_check(lambda fl: fl.is_insurance(domain))

     async def user_may_invite(
         self, inviter: str, invitee: str, room_id: str
     ) -> Literal["NOT_SPAM"] | errors.Codes:
         # Check local invites first, no need to check federation invites for those
-        # This verifies that local users can't invite into their DMs as verified by a few tests in the Testsuite
         if self.api.is_mine(inviter):
+            if self.config.tim_type == TimType.EPA:
+                # The TIM-ePA backend forbids all local invites
+                if self.api.is_mine(invitee):
+                    return errors.Codes.FORBIDDEN
+
+            # Verify that local users can't invite into their DMs as verified by a few tests in the Testsuite
             direct = await self.api.account_data_manager.get_global(
                 inviter, AccountDataTypes.DIRECT
             )
@@ -362,23 +432,34 @@ async def user_may_invite(
             # local invites are always valid, if they are not to a dm.
             # There are no checks done for outgoing invites apart from in the federation proxy,
             # which checks all requests.
-            # if self.api.is_mine(invitee):
             logger.debug("Local invite from %s to %s allowed", inviter, invitee)
             return NOT_SPAM

-        # Step 1, check federation allow list
         inviter_id = UserID.from_string(inviter)
         inviter_domain = inviter_id.domain
-        domain_set, _ = await self.fetch_federation_allow_list()
-        if inviter_domain not in domain_set:
-            self.fetch_federation_allow_list.cache_clear()
-            domain_set, _ = await self.fetch_federation_allow_list()

-            if inviter_domain not in domain_set:
-                logger.warning("Discarding invite from domain: %s", inviter_domain)
-                return errors.Codes.FORBIDDEN
+        # Step 1a, check federation allow list
+        if not await self.is_domain_allowed(inviter_domain):
+            logger.warning("Discarding invite from domain: %s", inviter_domain)
+            return errors.Codes.FORBIDDEN
+
+        # Step 1b
+        # Per AF_10233: Deny incoming remote invites if in ePA mode(which means the
+        # local user is an 'insured') and if the remote domain is type 'insurance'.
+
+        # TODO: stop trusting our local setting and instead check the federation list
+        #  data to see if *this* server is of type `isInsurance`
+        if self.config.tim_type == TimType.EPA and await self.is_domain_insurance(
+            inviter_domain
+        ):
+            logger.warning(
+                "Discarding invite from remote insurance domain: %s", inviter_domain
+            )
+            return errors.Codes.FORBIDDEN

         # Step 2, check invite settings
+        # TODO: This section checks for "Block all but allow exceptions" behavior. Need
+        #  to develop the inverse as well, "Allow all but block exceptions"
         contact = await self.store.get_contact(UserID.from_string(invitee), inviter)
         seconds_since_epoch = int(time.time())
         if (
@@ -398,7 +479,7 @@ async def user_may_invite(

         # Step 3, no active invite settings found, ensure we
         # - either invite an org
-        # - or both users are practitioners and the invitee has no restrcited visibility
+        # - or both users are practitioners and the invitee has no restricted visibility
         # The values org, pract, orgPract stand for org membership, practitioner and both respectively.
         invitee_loc = await self.fetch_localization_for_mxid(invitee)
         if invitee_loc in {"orgPract", "org"}:
@@ -415,7 +496,7 @@ async def user_may_invite(
         inviter_loc = await self.fetch_localization_for_mxid(inviter)
         if invitee_loc in visiblePract and inviter_loc in visiblePract:
             logger.debug(
-                "Allowing invite since invitee (%s) and inviter (%s) are both practioners (%s and %s)",
+                "Allowing invite since invitee (%s) and inviter (%s) are both practitioners (%s and %s)",
                 invitee,
                 inviter,
                 invitee_loc,
@@ -424,7 +505,7 @@ async def user_may_invite(
             return NOT_SPAM

         logger.debug(
-            "Not allowing invite since invitee (%s) and inviter (%s) are not both practioners (%s and %s) and all previous checks failed",
+            "Not allowing invite since invitee (%s) and inviter (%s) are not both practitioners (%s and %s) and all previous checks failed",
             invitee,
             inviter,
             invitee_loc,
diff --git a/synapse_invite_checker/rest/messenger_info.py b/synapse_invite_checker/rest/messenger_info.py
index c770a49..ffe1700 100644
--- a/synapse_invite_checker/rest/messenger_info.py
+++ b/synapse_invite_checker/rest/messenger_info.py
@@ -14,14 +14,16 @@
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
 import re
 from http import HTTPStatus
-from typing import List
+from typing import Awaitable, Callable, List

-from synapse.http.servlet import RestServlet
+from synapse.http.servlet import RestServlet, parse_string
 from synapse.http.site import SynapseRequest
+from synapse.module_api import errors
 from synapse.types import JsonDict

 from synapse_invite_checker.config import InviteCheckerConfig
 from synapse_invite_checker.rest.base import invite_checker_pattern
+from synapse_invite_checker.types import TimType

 # Version of TiMessengerInformation interface. See:
 # https://github.com/gematik/api-ti-messenger/blob/main/src/openapi/TiMessengerInformation.yaml
@@ -42,7 +44,6 @@ def __init__(self, config: InviteCheckerConfig):

         self.PATTERNS = tim_info_patterns("/$")

-    # @override
     async def on_GET(self, _: SynapseRequest) -> tuple[int, JsonDict]:
         return HTTPStatus.OK, {
             "title": self.config.title,
@@ -50,3 +51,29 @@ async def on_GET(self, _: SynapseRequest) -> tuple[int, JsonDict]:
             "contact": self.config.contact,
             "version": self.version,
         }
+
+
+class MessengerIsInsuranceResource(RestServlet):
+    def __init__(
+        self,
+        config: InviteCheckerConfig,
+        is_insurance_cb: Callable[[str], Awaitable[bool]],
+    ):
+        super().__init__()
+        self.config = config
+        self.is_insurance_cb = is_insurance_cb
+
+        self.PATTERNS = tim_info_patterns("/v1/server/isInsurance$")
+
+    async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
+        if self.config.tim_type == TimType.EPA:
+            return 401, {
+                "errorCode": errors.Codes.UNAUTHORIZED,
+                "errorMessage": "Unauthorized to access for ePA backend",
+            }
+
+        server_name = parse_string(request, "serverName", required=True)
+        isInsurance = await self.is_insurance_cb(server_name)
+        return HTTPStatus.OK, {
+            "isInsurance": isInsurance,
+        }
diff --git a/synapse_invite_checker/types.py b/synapse_invite_checker/types.py
index 5c30d63..3300c88 100644
--- a/synapse_invite_checker/types.py
+++ b/synapse_invite_checker/types.py
@@ -12,9 +12,10 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
+from enum import Enum, auto
+from functools import cached_property

-
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, computed_field

 class InviteSettings(BaseModel):
@@ -61,3 +62,40 @@ class FederationList(BaseModel):
     )

     domainList: list[FederationDomain]  # noqa: N815
+
+    @computed_field
+    @cached_property
+    def _domains_on_list(self) -> set[str]:
+        """
+        The deduplicated domains found on the Federation List
+        """
+        return {domain_data.domain for domain_data in self.domainList}
+
+    @computed_field
+    @cached_property
+    def _insurance_domains_on_list(self) -> set[str]:
+        """
+        Only the domains that are also type 'isInsurance'
+        """
+        return {
+            domain_data.domain
+            for domain_data in self.domainList
+            if domain_data.isInsurance
+        }
+
+    def allowed(self, domain: str) -> bool:
+        """
+        Compare against the domains from the Federation List to determine if they are allowed
+        """
+        return domain in self._domains_on_list
+
+    def is_insurance(self, domain: str) -> bool:
+        """
+        Is this domain specifically designated as 'isInsurance'
+        """
+        return domain in self._insurance_domains_on_list
+
+
+class TimType(Enum):
+    PRO = auto()
+    EPA = auto()

diff --git a/tests/base.py b/tests/base.py
index c7a7e0d..2bae754 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -236,6 +236,7 @@ def default_config(self) -> dict[str, Any]:
                 {
                     "module": "synapse_invite_checker.InviteChecker",
                     "config": {
+                        "tim-type": "pro",
                         "federation_list_url": "http://dummy.test/FederationList/federationList.jws",
                         "federation_localization_url": "http://dummy.test/localization",
                         "federation_list_client_cert": "tests/certs/client.pem",
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..98a2dda
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,55 @@
+# Copyright (C) 2020, 2024 Famedly
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+from unittest import TestCase
+
+from synapse.config import ConfigError
+
+from synapse_invite_checker import InviteChecker
+from synapse_invite_checker.types import TimType
+
+
+class ConfigParsingTestCase(TestCase):
+    """
+    Test that parsing the config can generate Exceptions.
+
+    We start with a basic configuration, and copy it for each test. That is then
+    modified to generate the Exceptions.
+
+    An empty string was chosen to represent a 'falsy' value, but removing the
+    key has the same effect. May use either interchangeably
+    """
+
+    config = {
+        "tim-type": "pro",
+        "federation_list_url": "https://localhost:8080",
+        "federation_localization_url": "https://localhost:8000/localization",
+        "federation_list_client_cert": "tests/certs/client.pem",
+        "gematik_ca_baseurl": "https://download-ref.tsl.ti-dienste.de/",
+    }
+
+    def test_tim_type_is_not_case_sensitive(self) -> None:
+        test_config = self.config.copy()
+        test_config.update({"tim-type": "ePA"})
+        assert InviteChecker.parse_config(test_config).tim_type == TimType.EPA
+
+    def test_tim_type_defaults_to_pro_mode(self) -> None:
+        test_config = self.config.copy()
+        test_config.pop("tim-type")
+        assert InviteChecker.parse_config(test_config).tim_type == TimType.PRO
+
+    def test_incorrect_tim_type_raises(self) -> None:
+        test_config = self.config.copy()
+        test_config.update({"tim-type": "fake"})
+        self.assertRaises(ConfigError, InviteChecker.parse_config, test_config)
diff --git a/tests/test_local_invites.py b/tests/test_local_invites.py
index 4341cca..8988abd 100644
--- a/tests/test_local_invites.py
+++ b/tests/test_local_invites.py
@@ -12,6 +12,8 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
+from typing import Any
+
 from synapse.server import HomeServer
 from synapse.util import Clock
 from twisted.internet.testing import MemoryReactor
@@ -19,14 +21,25 @@
 from tests.base import ModuleApiTestCase

-class LocalInviteTest(ModuleApiTestCase):
+class LocalProModeInviteTest(ModuleApiTestCase):
+    """
+    These tests do not cover invites during room creation.
+    """
+
     def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
         super().prepare(reactor, clock, homeserver)
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
         self.user_a = self.register_user("a", "password")
         self.access_token = self.login("a", "password")
         self.user_b = self.register_user("b", "password")
         self.user_c = self.register_user("c", "password")

+        # @d:test is none of those types of actor and should be just a 'User'. For
+        # context, this could be a chatbot or an office manager
+        self.user_d = self.register_user("d", "password")
+
         # authenticated as user_a
         self.helper.auth_user_id = self.user_a

@@ -35,7 +48,7 @@ def test_invite_to_dm(self) -> None:
         room_id = self.helper.create_room_as(
             self.user_a, is_public=False, tok=self.access_token
         )
-        assert room_id, "Room created"
+        assert room_id, "Room not created"

         # create DM event
         channel = self.make_request(
@@ -56,6 +69,13 @@ def test_invite_to_dm(self) -> None:
             tok=self.access_token,
             expect_code=403,
         )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_a,
+            targ=self.user_d,
+            tok=self.access_token,
+            expect_code=403,
+        )
         # But can invite the dm user
         self.helper.invite(
             room=room_id,
@@ -70,7 +90,7 @@ def test_invite_to_group(self) -> None:
         room_id = self.helper.create_room_as(
             self.user_a, is_public=False, tok=self.access_token
         )
-        assert room_id, "Room created"
+        assert room_id, "Room not created"

         # create DM event
         channel = self.make_request(
@@ -98,13 +118,20 @@ def test_invite_to_group(self) -> None:
             tok=self.access_token,
             expect_code=200,
         )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_a,
+            targ=self.user_d,
+            tok=self.access_token,
+            expect_code=200,
+        )

     def test_invite_to_group_without_dm_event(self) -> None:
         """Tests that a group with local users works normally in case the user has no m.direct set"""
         room_id = self.helper.create_room_as(
             self.user_a, is_public=False, tok=self.access_token
         )
-        assert room_id, "Room created"
+        assert room_id, "Room not created"

         # Can invite other users
         self.helper.invite(
@@ -121,3 +148,141 @@ def test_invite_to_group_without_dm_event(self) -> None:
             tok=self.access_token,
             expect_code=200,
         )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_a,
+            targ=self.user_d,
+            tok=self.access_token,
+            expect_code=200,
+        )
+
+
+class LocalEpaModeInviteTest(ModuleApiTestCase):
+    """
+    These tests do not cover invites during room creation.
+
+        NOTE: This should not be allowed to work. Strictly speaking, a server that is
+    in 'epa' mode should always appear on the federation list as an 'isInsurance'.
+    For the moment, all we do is log a warning. This will be changed in the future
+    which will require assuming the identity of an insurance domain to test with.
+
+    """
+
+    def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
+        super().prepare(reactor, clock, homeserver)
+        # Can't use any of:
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
+        # as they should not exist on an 'ePA' mode server backend
+
+        # 'd', 'e' and 'f' is none of those types of actor and should be just regular 'User's
+        self.user_d = self.register_user("d", "password")
+        self.user_e = self.register_user("e", "password")
+        self.user_f = self.register_user("f", "password")
+        self.access_token = self.login("d", "password")
+
+        # authenticated as user_d
+        self.helper.auth_user_id = self.user_d
+
+    def default_config(self) -> dict[str, Any]:
+        conf = super().default_config()
+        assert "modules" in conf, "modules missing from config dict during construction"
+
+        # There should only be a single item in the 'modules' list, since this tests that module
+        assert len(conf["modules"]) == 1, "more than one module found in config"
+
+        conf["modules"][0].setdefault("config", {}).update({"tim-type": "epa"})
+        return conf
+
+    def test_invite_to_dm_post_room_creation(self) -> None:
+        """Tests that a private room as a dm will deny inviting any local users"""
+        room_id = self.helper.create_room_as(
+            self.user_d, is_public=False, tok=self.access_token
+        )
+        assert room_id, "Room not created"
+
+        # create DM event
+        channel = self.make_request(
+            "PUT",
+            f"/user/{self.user_d}/account_data/m.direct",
+            {
+                self.user_e: [room_id],
+            },
+            access_token=self.access_token,
+        )
+        assert channel.code == 200, channel.result
+
+        # Can't invite other users
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_f,
+            tok=self.access_token,
+            expect_code=403,
+        )
+
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_e,
+            tok=self.access_token,
+            expect_code=403,
+        )
+
+    def test_invite_to_group_post_room_creation(self) -> None:
+        """Tests that a private room for a group will deny inviting any local users, with an unrelated m.direct tag"""
+        room_id = self.helper.create_room_as(
+            self.user_d, is_public=False, tok=self.access_token
+        )
+        assert room_id, "Room not created"
+
+        # create DM event
+        channel = self.make_request(
+            "PUT",
+            f"/user/{self.user_d}/account_data/m.direct",
+            {
+                self.user_e: ["!not:existing.example.com"],
+            },
+            access_token=self.access_token,
+        )
+        assert channel.code == 200, channel.result
+
+        # Can't invite other users
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_f,
+            tok=self.access_token,
+            expect_code=403,
+        )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_e,
+            tok=self.access_token,
+            expect_code=403,
+        )
+
+    def test_invite_to_group_without_dm_event_post_room_creation(self) -> None:
+        """Tests that a group with local users is denied when the user has no m.direct set"""
+        room_id = self.helper.create_room_as(
+            self.user_d, is_public=False, tok=self.access_token
+        )
+        assert room_id, "Room not created"
+
+        # Can't invite other users
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_f,
+            tok=self.access_token,
+            expect_code=403,
+        )
+        self.helper.invite(
+            room=room_id,
+            src=self.user_d,
+            targ=self.user_e,
+            tok=self.access_token,
+            expect_code=403,
+        )
diff --git a/tests/test_remote_invites.py b/tests/test_remote_invites.py
index 01b69d6..d6a79f2 100644
--- a/tests/test_remote_invites.py
+++ b/tests/test_remote_invites.py
@@ -12,6 +12,8 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
+from typing import Any
+
 from synapse.module_api import NOT_SPAM, errors
 from synapse.server import HomeServer
 from synapse.util import Clock
@@ -19,17 +21,27 @@
 from twisted.internet.testing import MemoryReactor

 from tests.base import ModuleApiTestCase
-from tests.test_utils import DOMAIN_IN_LIST
+from tests.test_utils import DOMAIN_IN_LIST, INSURANCE_DOMAIN_IN_LIST
+

+class RemoteProModeInviteTest(ModuleApiTestCase):
+    """Test remote invites in the default 'pro' mode behave as expected."""

-class RemoteInviteTest(ModuleApiTestCase):
     def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
         super().prepare(reactor, clock, homeserver)
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
         self.user_a = self.register_user("a", "password")
         self.access_token = self.login("a", "password")
         self.user_b = self.register_user("b", "password")
         self.user_c = self.register_user("c", "password")

+        # @d:test is none of those types of actor and should be just a 'User'. For
+        # context, this could be a chatbot or an office manager
+        self.user_d = self.register_user("d", "password")
+        self.access_token_d = self.login("d", "password")
+
         # authenticated as user_a
         self.helper.auth_user_id = self.user_a

@@ -76,37 +88,96 @@ async def test_invite_from_remote_outside_of_fed_list(self) -> None:
         )
         assert channel.code == 200, channel.result

+        # 'pract' sequence of tests. The practitioner tested is publicly listed and
+        # therefore doesn't need to have contact details
         assert (
             await self.may_invite(
                 f"@example:not-{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
-        # currently not testing modifying the fed list atm
+        ), f"inviter '@example:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_a} when not on fed list but DOES have contact details"
         assert (
             await self.may_invite(
                 "@example:messenger.spilikin.dev", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
+        ), f"inviter '@example:messenger.spilikin.dev' incorrectly ALLOWED to invite {self.user_a} without contact details"
         assert (
             await self.may_invite(
                 f"@example:{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == NOT_SPAM
-        )
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly FORBIDDEN to invite {self.user_a} despite contact details"
         assert (
             await self.may_invite(
                 f"@example2:not-{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
+        ), f"inviter '@example2:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_a}"
         assert (
             await self.may_invite(
                 f"@example2:{DOMAIN_IN_LIST}", self.user_a, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
+        ), f"inviter '@example2:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_a} without contact details"
+
+        # 'User' sequence of tests. Should not succeed without contact details of other
+        # party if they are on the fed list
+        assert (
+            await self.may_invite(
+                f"@example:not-{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d} when not on fed list but DOES have contact details"
+        assert (
+            await self.may_invite(
+                "@example:messenger.spilikin.dev", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:messenger.spilikin.dev' incorrectly ALLOWED to invite {self.user_d}"
+
+        assert (
+            await self.may_invite(
+                f"@example2:not-{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example2:not-{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d}"
+        assert (
+            await self.may_invite(
+                f"@example2:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example2:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d} without contact details"
+
+        # This single test should pass, but doesn't because 'd' has blocked all contacts
+        # unless added to their contact permissions. Do this below to test again
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d} without contact details"
+
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
         )
+        assert channel.code == 200, channel.result
+        # Now it should work
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == NOT_SPAM
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly FORBIDDEN to invite {self.user_d} despite contact details"

     async def test_invite_from_publicly_listed_practitioners(self) -> None:
         """Tests that an invite from a remote server gets accepted when in the federation list and both practitioners are public"""
@@ -125,11 +196,15 @@ async def test_invite_from_publicly_listed_practitioners(self) -> None:
             assert (
                 await self.may_invite(inviter, self.user_a, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_a}"
             assert (
                 await self.may_invite(inviter, self.user_c, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_c}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"

         for inviter in {
             f"@mxid404:{DOMAIN_IN_LIST}",
@@ -141,10 +216,15 @@ async def test_invite_from_publicly_listed_practitioners(self) -> None:
             assert (
                 await self.may_invite(inviter, self.user_a, "!madeup:example.com")
                 == errors.Codes.FORBIDDEN
-            )
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_a}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"

-    async def test_invite_from_remote_to_local_org(self) -> None:
-        """Tests that an invite from a remote server gets accepted when in the federation list and the invite is to an orgPract"""
+    async def test_invite_from_remote_to_local_org_various(self) -> None:
+        """Tests that an invite from a remote server gets accepted when in the
+        federation list and the invite is to an org or orgPract"""
         for inviter in {
             f"@mxid:{DOMAIN_IN_LIST}",
             f"@matrixuri:{DOMAIN_IN_LIST}",
@@ -162,24 +242,268 @@ async def test_invite_from_remote_to_local_org(self) -> None:
             f"@gematikuri404:{DOMAIN_IN_LIST}",
             f"@gematikuri2404:{DOMAIN_IN_LIST}",
         }:
+            # user 'b' is actually an 'org' not an 'orgPract'
             assert (
                 await self.may_invite(inviter, self.user_b, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_b}"
             assert (
                 await self.may_invite(inviter, self.user_c, "!madeup:example.com")
                 == NOT_SPAM
-            )
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_c}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"

         assert (
             await self.may_invite(
                 "@unknown:not.in.fed", self.user_b, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
-        )
+        ), f"inviter '@unknown:not.in.fed' incorrectly ALLOWED from {self.user_b}"
         assert (
             await self.may_invite(
                 "@unknown:not.in.fed", self.user_c, "!madeup:example.com"
             )
             == errors.Codes.FORBIDDEN
+        ), f"inviter '@unknown:not.in.fed' incorrectly ALLOWED from {self.user_c}"
+        assert (
+            await self.may_invite(
+                "@unknown:not.in.fed", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@unknown:not.in.fed' incorrectly ALLOWED from {self.user_d}"
+
+    async def test_remote_invite_from_an_insurance_domain(self) -> None:
+        """
+        Test that an insured user can invite a publicly listed practitioner or organization
+        (but not a regular user on the practitioner's domain)
+        """
+        for inviter in {
+            f"@unknown:{INSURANCE_DOMAIN_IN_LIST}",
+            f"@rando-32-b52:{INSURANCE_DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_b, "!madeup:example.com")
+                == NOT_SPAM
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_a}"
+            assert (
+                await self.may_invite(inviter, self.user_c, "!madeup:example.com")
+                == NOT_SPAM
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_b}"
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d} without contact details"
+
+
+class RemoteEpaModeInviteTest(ModuleApiTestCase):
+    """
+    Test remote invites in 'epa' mode have expected behavior.
+
+    Note that if the local server is in 'epa' mode, it means the server 'isInsurance'.
+    Therefore, it is the responsibility of the remote server to deny *our* invites.
+    Likewise, it is our responsibility to deny *theirs* if they are also 'isInsurance'.
+
+    The second behavior is what we test here
+
+        NOTE: This should not be allowed to work. Strictly speaking, a server that is
+    in 'epa' mode should always appear on the federation list as an 'isInsurance'.
+    For the moment, all we do is log a warning. This will be changed in the future
+    which will require assuming the identity of an insurance domain to test with.
+
+    """
+
+    def prepare(self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer):
+        super().prepare(reactor, clock, homeserver)
+        # Can't use any of:
+        #  @a:test is a practitioner
+        #  @b:test is an organization
+        #  @c:test is an 'orgPract'
+        # as they should not exist on an 'ePA' mode server backend
+
+        # 'd', 'e' and 'f' is none of those types of actor and should be just regular 'User's
+        self.user_d = self.register_user("d", "password")
+        self.access_token_d = self.login("d", "password")
+
+        # authenticated as user_d
+        self.helper.auth_user_id = self.user_d
+
+    def default_config(self) -> dict[str, Any]:
+        conf = super().default_config()
+        assert "modules" in conf, "modules missing from config dict during construction"
+
+        # There should only be a single item in the 'modules' list, since this tests that module
+        assert len(conf["modules"]) == 1, "more than one module found in config"
+
+        conf["modules"][0].setdefault("config", {}).update({"tim-type": "epa"})
+        return conf
+
+    async def may_invite(self, inviter: str, invitee: str, roomid: str):
+        req = defer.ensureDeferred(
+            self.hs.get_module_api()._callbacks.spam_checker.user_may_invite(
+                inviter, invitee, roomid
+            )
         )
+        self.wait_on_thread(req)
+        ret = self.get_success(req)
+        if ret == NOT_SPAM:
+            return NOT_SPAM
+        return ret[0]  # return first code instead of all of them to make assert easier
+
+    async def test_invite_from_remote_not_on_fed_list(self) -> None:
+        """Tests that an invite from a remote server not in the federation list gets denied"""
+
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
+        )
+        assert channel.code == 200, channel.result
+
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:not-{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
+        )
+        assert channel.code == 200, channel.result
+
+        for inviter in {
+            f"@example:not-{DOMAIN_IN_LIST}",
+            "@example:messenger.spilikin.dev",
+            f"@example2:not-{DOMAIN_IN_LIST}",
+            f"@example2:{DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter '{inviter}' incorrectly ALLOWED to invite {self.user_d}"
+
+    async def test_invite_from_remote_on_fed_list(self) -> None:
+        """Test that a remote user invite from a domain on the fed list only succeeds with contact details"""
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == errors.Codes.FORBIDDEN
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly ALLOWED to invite {self.user_d}"
+
+        # Now add in the contact details and try again
+        channel = self.make_request(
+            "PUT",
+            "/_synapse/client/com.famedly/tim/v1/contacts",
+            {
+                "displayName": "Test User",
+                "mxid": f"@example:{DOMAIN_IN_LIST}",
+                "inviteSettings": {
+                    "start": 0,
+                },
+            },
+            access_token=self.access_token_d,
+        )
+        assert channel.code == 200, channel.result
+
+        assert (
+            await self.may_invite(
+                f"@example:{DOMAIN_IN_LIST}", self.user_d, "!madeup:example.com"
+            )
+            == NOT_SPAM
+        ), f"inviter '@example:{DOMAIN_IN_LIST}' incorrectly FORBIDDEN to invite {self.user_d}"
+
+    async def test_invite_from_remote_practitioners(self) -> None:
+        """
+        Tests that an invite from a remote server gets accepted when in the federation
+        list, and it is not 'isInsurance'. Borrow our localization setup for this
+        """
+        for inviter in {
+            f"@mxid:{DOMAIN_IN_LIST}",
+            f"@matrixuri:{DOMAIN_IN_LIST}",
+            f"@matrixuri2:{DOMAIN_IN_LIST}",
+            f"@gematikuri:{DOMAIN_IN_LIST}",
+            f"@gematikuri2:{DOMAIN_IN_LIST}",
+            f"@mxidorgpract:{DOMAIN_IN_LIST}",
+            f"@matrixuriorgpract:{DOMAIN_IN_LIST}",
+            f"@matrixuri2orgpract:{DOMAIN_IN_LIST}",
+            f"@gematikuriorgpract:{DOMAIN_IN_LIST}",
+            f"@gematikuri2orgpract:{DOMAIN_IN_LIST}",
+            f"@mxid404:{DOMAIN_IN_LIST}",
+            f"@matrixuri404:{DOMAIN_IN_LIST}",
+            f"@matrixuri2404:{DOMAIN_IN_LIST}",
+            f"@gematikuri404:{DOMAIN_IN_LIST}",
+            f"@gematikuri2404:{DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d}(step one)"
+
+            # Now add in the contact details...
+            channel = self.make_request(
+                "PUT",
+                "/_synapse/client/com.famedly/tim/v1/contacts",
+                {
+                    "displayName": "Test User",
+                    "mxid": f"{inviter}",
+                    "inviteSettings": {
+                        "start": 0,
+                    },
+                },
+                access_token=self.access_token_d,
+            )
+            assert channel.code == 200, channel.result
+
+            # ...and try again
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == NOT_SPAM
+            ), f"inviter {inviter} incorrectly FORBIDDEN from {self.user_d}(step two)"
+
+    async def test_remote_invite_from_an_insured_domain_fails(self) -> None:
+        """
+        Test that invites from another insurance domain are rejected with or without
+        contact permissions
+        """
+        for inviter in {
+            f"@unknown:{INSURANCE_DOMAIN_IN_LIST}",
+            f"@rando-32-b52:{INSURANCE_DOMAIN_IN_LIST}",
+        }:
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d}"
+
+            # Now add in the contact details...
+            channel = self.make_request(
+                "PUT",
+                "/_synapse/client/com.famedly/tim/v1/contacts",
+                {
+                    "displayName": "Test User",
+                    "mxid": f"{inviter}",
+                    "inviteSettings": {
+                        "start": 0,
+                    },
+                },
+                access_token=self.access_token_d,
+            )
+            assert channel.code == 200, channel.result
+
+            # ...and try again
+            assert (
+                await self.may_invite(inviter, self.user_d, "!madeup:example.com")
+                == errors.Codes.FORBIDDEN
+            ), f"inviter {inviter} incorrectly ALLOWED from {self.user_d}"
diff --git a/tests/test_tim_federation.py b/tests/test_tim_federation.py
index e4c2bd6..2339b2b 100644
--- a/tests/test_tim_federation.py
+++ b/tests/test_tim_federation.py
@@ -19,6 +19,7 @@
 from synapse_invite_checker import InviteChecker
 from synapse_invite_checker.types import FederationDomain
 from tests.base import ModuleApiTestCase
+from tests.test_utils import DOMAIN_IN_LIST

 class FederationDomainSchemaTest(ModuleApiTestCase):
@@ -47,12 +48,13 @@ def prepare(

     async def extract_entry_from_domainList(self, domain: str) -> FederationDomain:
         """
-        Search for a specific domain in the federation list
+        Search for a specific domain in the federation list to extract it's associated
+        data. Normally this additional information is not used
         """
-        _, raw_fed_list = await self.invchecker.fetch_federation_allow_list()
-        assert len(raw_fed_list.domainList) > 0
+        fed_list = await self.invchecker._fetch_federation_list()
+        assert len(fed_list.domainList) > 0

-        for domain_entry in raw_fed_list.domainList:
+        for domain_entry in fed_list.domainList:
             if domain_entry.domain == domain:
                 return domain_entry

@@ -61,8 +63,39 @@ async def extract_entry_from_domainList(self, domain: str) -> FederationDomain:
     async def test_federation_list(self) -> None:
         """Ensure we can properly fetch the federation list"""

-        domains, _ = await self.invchecker.fetch_federation_allow_list()
-        assert "timo.staging.famedly.de" in domains
+        fed_list = await self.invchecker._fetch_federation_list()
+        assert fed_list.allowed("timo.staging.famedly.de")
+
+    async def test_is_insurance(self) -> None:
+        """Ensure we can properly determine if a domain is insurance"""
+
+        fed_list = await self.invchecker._fetch_federation_list()
+        assert fed_list.is_insurance("cirosec.de")
+
+    async def test_is_domain_allowed_uses_cache(self) -> None:
+        # Simple procedural test. It does not increase coverage(at the time it was
+        # written), but did prove to me that it was working.
+        # 1. clear the count on the mock of called instances
+        # 2. get the value, which should increase counter to 1
+        # 3. check that calls == 1, reset mock to 0
+        # 4. get the value again, which should retrieve from cache and have a count of 0
+        self.invchecker._raw_federation_list_fetch.reset_mock()
+
+        self.invchecker._raw_federation_list_fetch.assert_not_called()
+
+        should_be_true = await self.invchecker.is_domain_allowed(DOMAIN_IN_LIST)
+        assert should_be_true, "tested domain was not allowed but should have been"
+
+        self.invchecker._raw_federation_list_fetch.assert_called_once()
+
+        self.invchecker._raw_federation_list_fetch.reset_mock()
+
+        should_still_be_true = await self.invchecker.is_domain_allowed(DOMAIN_IN_LIST)
+        assert (
+            should_still_be_true
+        ), "tested domain was still not allowed but should have been"
+
+        self.invchecker._raw_federation_list_fetch.assert_not_called()

     async def test_common_fed_domain(self):
         # First test the most common FederationDomain entry
diff --git a/tests/test_tim_information.py b/tests/test_tim_information.py
index 4a1a40a..8cbd147 100644
--- a/tests/test_tim_information.py
+++ b/tests/test_tim_information.py
@@ -62,3 +62,63 @@ def test_custom_operator_contact_info_resource(self) -> None:
         assert channel.json_body["description"] == "def"
         assert channel.json_body["contact"] == "ghi"
         assert channel.json_body["version"], "Version returned"
+
+
+class MessengerIsInsuranceResourceTest(ModuleApiTestCase):
+    def test_pro_isInsurance_returns_expected(self) -> None:
+        """Tests that Pro mode returns expected response"""
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=cirosec.de",
+        )
+
+        assert channel.code == 200, channel.result
+        assert channel.json_body[
+            "isInsurance"
+        ], "isInsurance is FALSE when it should be TRUE"
+
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=timo.staging.famedly.de",
+        )
+
+        assert channel.code == 200, channel.result
+        assert not channel.json_body[
+            "isInsurance"
+        ], "isInsurance is TRUE when it should be FALSE"
+
+    @synapse_test.override_config(
+        {
+            "modules": [
+                {
+                    "module": "synapse_invite_checker.InviteChecker",
+                    "config": {
+                        "tim-type": "epa",
+                        "title": "abc",
+                        "description": "def",
+                        "contact": "ghi",
+                        "federation_list_url": "https://localhost:8080",
+                        "federation_localization_url": "https://localhost:8000/localization",
+                        "federation_list_client_cert": "tests/certs/client.pem",
+                        "gematik_ca_baseurl": "https://download-ref.tsl.ti-dienste.de/",
+                    },
+                }
+            ]
+        }
+    )
+    def test_epa_isInsurance_returns_expected(self) -> None:
+        """Tests that ePA mode returns expected response"""
+
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=cirosec.de",
+        )
+
+        assert channel.code == 401, channel.result
+
+        channel = self.make_request(
+            method="GET",
+            path="/_synapse/client/com.famedly/tim/tim-information/v1/server/isInsurance?serverName=timo.staging.famedly.de",
+        )
+
+        assert channel.code == 401, channel.result
diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py
index dbbee5d..71a3143 100644
--- a/tests/test_utils/__init__.py
+++ b/tests/test_utils/__init__.py
@@ -40,6 +40,8 @@

 # Use a domain found in our raw fedlist data instead of inserting a fake one
 DOMAIN_IN_LIST = "timo.staging.famedly.de"
+# Also borrow an 'isInsurance' domain for testing
+INSURANCE_DOMAIN_IN_LIST = "cirosec.de"

 def get_awaitable_result(awaitable: Awaitable[TV]) -> TV:

GET /v1/server/isInsurance
```
  /v1/server/isInsurance:
    get:
      tags:
        - lookUpServer
      summary: "Check whether a TI-Messenger server name represents an insurance."
      parameters:
        - in: query
          name: serverName
          description: "The server name to query."
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "OK"
          content:
            application/json:
              schema:
                type: object
                required:
                  - isInsurance
                properties:
                  isInsurance:
                    type: boolean
                    description: "`true` if the server name represents an insurance or `false` otherwise."
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
```

This endpoint will return the information if a server is an insurance server or not. This information is stored on the federationlist. Currently there is no test environment for the federation list and also the schema provided by gematik ([link](https://github.com/gematik/api-vzd/blob/main/src/schema/FederationList.json)) is not updated. To be able to implement this, we need to use the openapi spec, there they have the new definition ([link](https://github.com/gematik/api-vzd/blob/05a042ca67d49d231042576234aee940a12a6a52/src/openapi/I_VZD_TIM_Provider_Services.yaml#L510-L548)).

This endpoint should only be enabled in tim-pro mode.


diff --git a/news/13079.bugfix.rst b/news/13079.bugfix.rst
new file mode 100644
index 00000000000..5b297f5a12e
--- /dev/null
+++ b/news/13079.bugfix.rst
@@ -0,0 +1,1 @@
+This change fixes a security bug allowing a wheel to execute code during installation.
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index ad45a2f2a57..70acf202be9 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -10,6 +10,13 @@
 from pip._vendor.packaging.utils import canonicalize_name
 from pip._vendor.rich import print_json

+# Eagerly import self_outdated_check to avoid crashes. Otherwise,
+# this module would be imported *after* pip was replaced, resulting
+# in crashes if the new self_outdated_check module was incompatible
+# with the rest of pip that's already imported, or allowing a
+# wheel to execute arbitrary code on install by replacing
+# self_outdated_check.
+import pip._internal.self_outdated_check  # noqa: F401
 from pip._internal.cache import WheelCache
 from pip._internal.cli import cmdoptions
 from pip._internal.cli.cmdoptions import make_target_python
@@ -408,12 +415,6 @@ def run(self, options: Values, args: List[str]) -> int:
                 # If we're not replacing an already installed pip,
                 # we're not modifying it.
                 modifying_pip = pip_req.satisfied_by is None
-                if modifying_pip:
-                    # Eagerly import this module to avoid crashes. Otherwise, this
-                    # module would be imported *after* pip was replaced, resulting in
-                    # crashes if the new self_outdated_check module was incompatible
-                    # with the rest of pip that's already imported.
-                    import pip._internal.self_outdated_check  # noqa: F401
             protect_pip_from_modification_on_windows(modifying_pip=modifying_pip)

             reqs_to_build = [

Lazy import allows wheel to execute code on install.

### Description

Versions of pip since 24.1b1 allow someone to run arbitrary code after a specially crafted bdist whl file is installed.

When installing wheel files pip does not constrain the directories the wheel contents are written into, except for checks that ensure traversal is only within the destination directories (e.g, purelib, platlib, data, etc) (see #4625)

This means a wheel is able to place files into existing modules that belong to other packages, such as pip, setuptools, etc.

If the installer lazily imports a module *after* the wheel is installed it is possible for the wheel to overwrite the module with its own code, which is then imported unintentionally by the installer.

For pip, this has been true since 24.1b1 when a change was introduced that dynamically loads the `pip._internal.self_outdated_check` module after running a command to check if pip needs upgrading.

Because this module is loaded *after* a package has been installed, a wheel can overwrite `{purelib}/pip/_internal/self_outdated_check.py` and have the code within it automatically executed when `pip install {wheel}` is run.

>

### Expected behavior

This behavior is surprising. My understanding is that most Python users expect wheels can't run code during installation.

For example, the recent blog post on [command jacking](https://checkmarx.com/blog/this-new-supply-chain-attack-technique-can-trojanize-all-your-cli-commands/) demonstrates this expectation:

> Python wheels (.whl files) have become increasingly prevalent due to their performance benefits in package installation. However, they present a unique challenge for attackers
>
> While both .tar.gz and .whl files may contain a setup.py file, .whl files don’t execute setup.py during installation. This characteristic has traditionally made it more difficult for attackers to achieve arbitrary code execution during the installation process when using .whl files.

That said, the [wheel spec](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#binary-distribution-format) says nothing about security, or avoiding on-install code execution.

### pip version

24.1b1

### Python version

v3.11 later

### OS

any

### How to Reproduce

1. Download [wheelofdespair-0.0.1-py3-none-any.zip](https://github.com/user-attachments/files/17757896/wheelofdespair-0.0.1-py3-none-any.zip)
2. `mv wheelofdespair-0.0.1-py3-none-any.zip wheelofdespair-0.0.1-py3-none-any.whl`
3. `python3 -m venv env`
4. `. env/bin/activate`
5. `pip install --upgrade pip`
6. `pip install wheelofdespair-0.0.1-py3-none-any.whl`

### Output

```
Collecting wheelofdespair
  Downloading wheelofdespair-0.0.1-py3-none-any.whl.metadata (201 bytes)
Downloading wheelofdespair-0.0.1-py3-none-any.whl (1.5 kB)
Installing collected packages: wheelofdespair
Successfully installed wheelofdespair-0.0.1
PoC: Wheel-of-Despair code execution.
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).
Lazy import allows wheel to execute code on install.

### Description

Versions of pip since 24.1b1 allow someone to run arbitrary code after a specially crafted bdist whl file is installed.

When installing wheel files pip does not constrain the directories the wheel contents are written into, except for checks that ensure traversal is only within the destination directories (e.g, purelib, platlib, data, etc) (see #4625)

This means a wheel is able to place files into existing modules that belong to other packages, such as pip, setuptools, etc.

If the installer lazily imports a module *after* the wheel is installed it is possible for the wheel to overwrite the module with its own code, which is then imported unintentionally by the installer.

For pip, this has been true since 24.1b1 when a change was introduced that dynamically loads the `pip._internal.self_outdated_check` module after running a command to check if pip needs upgrading.

Because this module is loaded *after* a package has been installed, a wheel can overwrite `{purelib}/pip/_internal/self_outdated_check.py` and have the code within it automatically executed when `pip install {wheel}` is run.

>

### Expected behavior

This behavior is surprising. My understanding is that most Python users expect wheels can't run code during installation.

For example, the recent blog post on [command jacking](https://checkmarx.com/blog/this-new-supply-chain-attack-technique-can-trojanize-all-your-cli-commands/) demonstrates this expectation:

> Python wheels (.whl files) have become increasingly prevalent due to their performance benefits in package installation. However, they present a unique challenge for attackers
>
> While both .tar.gz and .whl files may contain a setup.py file, .whl files don’t execute setup.py during installation. This characteristic has traditionally made it more difficult for attackers to achieve arbitrary code execution during the installation process when using .whl files.

That said, the [wheel spec](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#binary-distribution-format) says nothing about security, or avoiding on-install code execution.

### pip version

24.1b1

### Python version

v3.11 later

### OS

any

### How to Reproduce

1. Download [wheelofdespair-0.0.1-py3-none-any.zip](https://github.com/user-attachments/files/17757896/wheelofdespair-0.0.1-py3-none-any.zip)
2. `mv wheelofdespair-0.0.1-py3-none-any.zip wheelofdespair-0.0.1-py3-none-any.whl`
3. `python3 -m venv env`
4. `. env/bin/activate`
5. `pip install --upgrade pip`
6. `pip install wheelofdespair-0.0.1-py3-none-any.whl`

### Output

```
Collecting wheelofdespair
  Downloading wheelofdespair-0.0.1-py3-none-any.whl.metadata (201 bytes)
Downloading wheelofdespair-0.0.1-py3-none-any.whl (1.5 kB)
Installing collected packages: wheelofdespair
Successfully installed wheelofdespair-0.0.1
PoC: Wheel-of-Despair code execution.
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).

Just a couple more additions:

- this behavior is related to `pip` installing into the same location that `pip` is running from

- this may have security implications based on the usage of `pip` by users. For example, `pip install --only-binary :all:` could be used in a trusted context, before using the installed packages in an untrusted context (e.g. different stages in a build pipeline).
I'll note that there are other ways to compromise pip. A malicious wheel could replace a key file used by pip, which is then picked up on the _next_ invocation. Or they could replace the pip script on PATH. Etc.

But yeah, this does make it easier to achieve arbitrary code execution as it only requires one invocation. We already eagerly import the self-check module when upgrading pip (to avoid crashes). It would be reasonable to always import the module eagerly in the install command module. https://github.com/pypa/pip/blob/fe0925b3c00bf8956a0d33408df692ac364217d4/src/pip/_internal/commands/install.py#L411-L416

Feel free to send a PR. Thanks for investigating and letting us know!

P.S. I haven't looked at this in detail, but I suspect there are other lazy imports in the codebase. Not sure if they're suspectible to ACE or not.
Thanks @ichard26 for the quick triage.

Looking at strace during `pip install`, the only other import I can see is `pip._internal.utils.entrypoints` but that appears to be imported through `pip._internal.self_outdated_check`.

I'll create a PR for this, but would you still like to keep the lazy loading except for `install` (i.e. remove the `if modifying_pip` condition but keep the import where it is), or would you prefer to make it non-lazy globally and import at the top of `pip._internal.cli.index_command`?

The import was made lazy in order to avoid importing the entire network and index (HTML) parsing stack. This improves start-up time for the commands that don't need these components. For example, `pip list` is an index command, but usually does not access the network at all and thus should not perform a self-check or import the machinery needed for the self-check. The tricky part is that a command like `pip list --outdated` does require the network and can perform a self-check. This makes an eager import at the top of `cli.index_command` unacceptable.

> (i.e. remove the if modifying_pip condition but keep the import where it is)

It'd probably be more robust to simply import the self-check at the top of `commands.install`.
Would definitely be great to fix this if possible, but I'm curious about setting a precedent here: is this behavior `pip` would be willing to guarantee even if the wheel spec does not specifically address it? Or is this only a best-effort fix?

If the goal is to guarantee the behavior, maybe @calebbrown you would be willing to help write a test here that would prevent a future regression, and this could be documented as well?
I don't think we'd want to guarantee this.

The fact that a wheel can install files for an arbitrary import package is a feature, not a bug[^1] - pillow installs PIL, setuptools installs pkg_resources, etc. The fact that pip allows a wheel to install files that overwrite those of an existing package is a known issue, and https://github.com/pypa/pip/issues/4625 is tracking this. As you'll notice if you read that issue, it's not a trivial problem to fix. The fact that "lazy" imports[^2] are affected if you alter the contents of `sys.path` while the program is running is a feature of Python's import system.

So while I'd be fine with a change that removes this specific issue, and as a result reduces the risk of problems, I don't think it's something we should try to guarantee. Users need to understand that when they install a wheel, it can affect the behaviour of both programs they subsequently run, and currently running programs. That isn't just pip - to give another example, if you have a service running from a Python environment and you install something new in that environment, the service can be affected. Ultimately, it is the user's responsibility to ensure that they only install trusted packages.

If someone wanted to write a section for the packaging user guide covering the trust and threat models for Python packaging, I'm sure that would be extremely useful.

[^1]: Although it's a feature that's open to abuse, and we could consider changing it, if anyone had the stomach for addressing the backward compatibility issues.
[^2]: They aren't technically "lazy", they just aren't done at program startup.
At the risk of getting quoted if/when this gets used by a bad actor: I would argue that we shouldn't fix things we don't plan to keep fixed. If this is just a subclass of #4625 and would be resolved there, seems like this would be considered a duplicate of that issue, even if it's a novel path to reproduce it.
I think the vector of attacking the running instance of pip is unexpected enough that we should cover ourselves against it. There's no point making things easier for attackers. I just don't think we should *guarantee* it, as that suggests that users don't need to worry about this. And honestly, I think that anyone who is genuinely concerned about Python security should be made *more* aware that it's their responsibility to do due dilligence, rather than assuming that volunteer projects are willing to cover that for them.

To that end, I'd strongly favour adding a security section to the packaging user guide, as I mentioned above. But I have neither the expertise nor the time to write such a thing myself.
As an outsider, a potential solution that would solve this for `pip` would be to prevent any package other than `pip` from updating `pip`.

This would leave #4625 unaddressed, but protect pip.
I can come up with attack vectors that would get past this (drop a `.pth` file into site-packages that modifies `sys.path` to put an override to pip ahead of pip itself, for example). So my position is unchanged - I think it's OK to protect what we can, but we shouldn't give anyone the impression that we guarantee that no harm is possible.
I'm going to tack this onto the 25.0 milestone so we don't forget to address this trivially fixable vulnerability. Larger discussions on pip security can occur later.
Just a couple more additions:

- this behavior is related to `pip` installing into the same location that `pip` is running from

- this may have security implications based on the usage of `pip` by users. For example, `pip install --only-binary :all:` could be used in a trusted context, before using the installed packages in an untrusted context (e.g. different stages in a build pipeline).
I'll note that there are other ways to compromise pip. A malicious wheel could replace a key file used by pip, which is then picked up on the _next_ invocation. Or they could replace the pip script on PATH. Etc.

But yeah, this does make it easier to achieve arbitrary code execution as it only requires one invocation. We already eagerly import the self-check module when upgrading pip (to avoid crashes). It would be reasonable to always import the module eagerly in the install command module. https://github.com/pypa/pip/blob/fe0925b3c00bf8956a0d33408df692ac364217d4/src/pip/_internal/commands/install.py#L411-L416

Feel free to send a PR. Thanks for investigating and letting us know!

P.S. I haven't looked at this in detail, but I suspect there are other lazy imports in the codebase. Not sure if they're suspectible to ACE or not.
Thanks @ichard26 for the quick triage.

Looking at strace during `pip install`, the only other import I can see is `pip._internal.utils.entrypoints` but that appears to be imported through `pip._internal.self_outdated_check`.

I'll create a PR for this, but would you still like to keep the lazy loading except for `install` (i.e. remove the `if modifying_pip` condition but keep the import where it is), or would you prefer to make it non-lazy globally and import at the top of `pip._internal.cli.index_command`?

The import was made lazy in order to avoid importing the entire network and index (HTML) parsing stack. This improves start-up time for the commands that don't need these components. For example, `pip list` is an index command, but usually does not access the network at all and thus should not perform a self-check or import the machinery needed for the self-check. The tricky part is that a command like `pip list --outdated` does require the network and can perform a self-check. This makes an eager import at the top of `cli.index_command` unacceptable.

> (i.e. remove the if modifying_pip condition but keep the import where it is)

It'd probably be more robust to simply import the self-check at the top of `commands.install`.
Would definitely be great to fix this if possible, but I'm curious about setting a precedent here: is this behavior `pip` would be willing to guarantee even if the wheel spec does not specifically address it? Or is this only a best-effort fix?

If the goal is to guarantee the behavior, maybe @calebbrown you would be willing to help write a test here that would prevent a future regression, and this could be documented as well?
I don't think we'd want to guarantee this.

The fact that a wheel can install files for an arbitrary import package is a feature, not a bug[^1] - pillow installs PIL, setuptools installs pkg_resources, etc. The fact that pip allows a wheel to install files that overwrite those of an existing package is a known issue, and https://github.com/pypa/pip/issues/4625 is tracking this. As you'll notice if you read that issue, it's not a trivial problem to fix. The fact that "lazy" imports[^2] are affected if you alter the contents of `sys.path` while the program is running is a feature of Python's import system.

So while I'd be fine with a change that removes this specific issue, and as a result reduces the risk of problems, I don't think it's something we should try to guarantee. Users need to understand that when they install a wheel, it can affect the behaviour of both programs they subsequently run, and currently running programs. That isn't just pip - to give another example, if you have a service running from a Python environment and you install something new in that environment, the service can be affected. Ultimately, it is the user's responsibility to ensure that they only install trusted packages.

If someone wanted to write a section for the packaging user guide covering the trust and threat models for Python packaging, I'm sure that would be extremely useful.

[^1]: Although it's a feature that's open to abuse, and we could consider changing it, if anyone had the stomach for addressing the backward compatibility issues.
[^2]: They aren't technically "lazy", they just aren't done at program startup.
At the risk of getting quoted if/when this gets used by a bad actor: I would argue that we shouldn't fix things we don't plan to keep fixed. If this is just a subclass of #4625 and would be resolved there, seems like this would be considered a duplicate of that issue, even if it's a novel path to reproduce it.
I think the vector of attacking the running instance of pip is unexpected enough that we should cover ourselves against it. There's no point making things easier for attackers. I just don't think we should *guarantee* it, as that suggests that users don't need to worry about this. And honestly, I think that anyone who is genuinely concerned about Python security should be made *more* aware that it's their responsibility to do due dilligence, rather than assuming that volunteer projects are willing to cover that for them.

To that end, I'd strongly favour adding a security section to the packaging user guide, as I mentioned above. But I have neither the expertise nor the time to write such a thing myself.
As an outsider, a potential solution that would solve this for `pip` would be to prevent any package other than `pip` from updating `pip`.

This would leave #4625 unaddressed, but protect pip.
I can come up with attack vectors that would get past this (drop a `.pth` file into site-packages that modifies `sys.path` to put an override to pip ahead of pip itself, for example). So my position is unchanged - I think it's OK to protect what we can, but we shouldn't give anyone the impression that we guarantee that no harm is possible.
I'm going to tack this onto the 25.0 milestone so we don't forget to address this trivially fixable vulnerability. Larger discussions on pip security can occur later.


diff --git a/simtools/applications/db_get_parameter_from_db.py b/simtools/applications/db_get_parameter_from_db.py
index 259f7689f..ff4f407fd 100644
--- a/simtools/applications/db_get_parameter_from_db.py
+++ b/simtools/applications/db_get_parameter_from_db.py
@@ -85,8 +85,11 @@ def main():  # noqa: D103
     db = db_handler.DatabaseHandler(mongo_db_config=db_config)

     if args_dict["db_collection"] == "configuration_sim_telarray":
-        pars = db.get_sim_telarray_configuration_parameters(
-            args_dict["site"], args_dict["telescope"], args_dict["model_version"]
+        pars = db.get_model_parameters(
+            args_dict["site"],
+            args_dict["telescope"],
+            args_dict["model_version"],
+            collection="configuration_sim_telarray",
         )
     elif args_dict["db_collection"] == "configuration_corsika":
         pars = db.get_corsika_configuration_parameters(args_dict["model_version"])
diff --git a/simtools/db/db_array_elements.py b/simtools/db/db_array_elements.py
index b0e4f961d..cb5d70ab5 100644
--- a/simtools/db/db_array_elements.py
+++ b/simtools/db/db_array_elements.py
@@ -1,12 +1,14 @@
 """Retrieval of array elements from the database."""

-from functools import cache
+from functools import lru_cache

 from pymongo import ASCENDING

+from simtools.utils import names

-@cache
-def get_array_elements(db, model_version, collection):
+
+@lru_cache
+def get_array_elements(db_collection, model_version):
     """
     Get all array element names and their design model for a given DB collection.

@@ -15,12 +17,10 @@ def get_array_elements(db, model_version, collection):

     Parameters
     ----------
-    db: DBHandler
-        Instance of the database handler
+    db_collection:
+        pymongo.collection.Collection
     model_version: str
        Model version.
-    collection: str
-        Database collection (e.g., telescopes, calibration_devices)

     Returns
     -------
@@ -35,9 +35,7 @@ def get_array_elements(db, model_version, collection):
         If array element entry in the database is incomplete.

     """
-    db_collection = db.get_collection(db_name=None, collection_name=collection)
-
-    query = {"version": db.model_version(model_version)}
+    query = {"version": model_version}
     results = db_collection.find(query, {"instrument": 1, "value": 1, "parameter": 1}).sort(
         "instrument", ASCENDING
     )
@@ -51,7 +49,7 @@ def get_array_elements(db, model_version, collection):
             raise KeyError(f"Incomplete array element entry in the database: {doc}.") from exc

     if len(_all_available_array_elements) == 0:
-        raise ValueError(f"No array elements found in DB collection {collection}.")
+        raise ValueError(f"No array elements found in DB collection {db_collection}.")

     return _all_available_array_elements

@@ -62,6 +60,7 @@ def get_array_element_list_for_db_query(array_element_name, db, model_version, c

     Return a list of array element names to be used for querying the database for a given array
     element. This is in most cases the array element itself and its design model.
+    In cases of no design model available, the design model of the array element is returned.

     Parameters
     ----------
@@ -80,7 +79,13 @@ def get_array_element_list_for_db_query(array_element_name, db, model_version, c
         List of array element model names as used in the DB.

     """
-    _available_array_elements = get_array_elements(db, model_version, collection)
+    try:
+        _available_array_elements = get_array_elements(
+            db.get_collection(db_name=None, collection_name=collection),
+            db.model_version(model_version),
+        )
+    except ValueError:
+        return [names.get_array_element_type_from_name(array_element_name) + "-design"]
     try:
         return [_available_array_elements[array_element_name], array_element_name]
     except KeyError:
@@ -116,7 +121,10 @@ def get_array_elements_of_type(array_element_type, db, model_version, collection
         Sorted list of all array element names found in collection

     """
-    _available_array_elements = get_array_elements(db, model_version, collection)
+    _available_array_elements = get_array_elements(
+        db.get_collection(db_name=None, collection_name=collection),
+        db.model_version(model_version),
+    )
     return sorted(
         [entry for entry in _available_array_elements if entry.startswith(array_element_type)]
     )
diff --git a/simtools/db/db_handler.py b/simtools/db/db_handler.py
index 0fa59e2f6..30a9e219d 100644
--- a/simtools/db/db_handler.py
+++ b/simtools/db/db_handler.py
@@ -50,7 +50,6 @@ class DatabaseHandler:
     site_parameters_cached = {}
     model_parameters_cached = {}
     model_versions_cached = {}
-    sim_telarray_configuration_parameters_cached = {}
     corsika_configuration_parameters_cached = {}

     def __init__(self, mongo_db_config=None):
@@ -192,7 +191,7 @@ def get_model_parameters(
         pars = {}
         for array_element in array_element_list:
             _array_elements_cache_key = self._parameter_cache_key(
-                site, array_element, model_version
+                site, array_element, model_version, collection
             )
             try:
                 pars.update(DatabaseHandler.model_parameters_cached[_array_elements_cache_key])
@@ -536,8 +535,8 @@ def get_simulation_configuration_parameters(
             return self.get_corsika_configuration_parameters(model_version)
         if simulation_software == "simtel":
             if site and array_element_name:
-                return self.get_sim_telarray_configuration_parameters(
-                    site, array_element_name, model_version
+                return self.get_model_parameters(
+                    site, array_element_name, model_version, collection="configuration_sim_telarray"
                 )
             return {}
         raise ValueError(f"Unknown simulation software: {simulation_software}")
@@ -573,60 +572,6 @@ def get_corsika_configuration_parameters(self, model_version):
         )
         return DatabaseHandler.corsika_configuration_parameters_cached[_corsika_cache_key]

-    def get_sim_telarray_configuration_parameters(self, site, array_element_name, model_version):
-        """
-        Get sim_telarray configuration parameters from the DB for a specific array element.
-
-        Parameters
-        ----------
-        site : str
-            Site name.
-        array_element_name : str
-            Name of the array element model (e.g. MSTN).
-        model_version : str
-            Version of the model.
-
-        Returns
-        -------
-        dict
-            Configuration parameters for sim_telarray
-        """
-        _, _array_element_name, _model_version = self._validate_model_input(
-            site, array_element_name, model_version
-        )
-        _array_elements_cache_key = self._parameter_cache_key(
-            site, array_element_name, model_version
-        )
-        try:
-            return DatabaseHandler.sim_telarray_configuration_parameters_cached[
-                _array_elements_cache_key
-            ]
-        except KeyError:
-            pass
-        pars = {}
-        try:
-            pars = self.read_mongo_db(
-                self._get_db_name(),
-                _array_element_name,
-                _model_version,
-                run_location=None,
-                collection_name="configuration_sim_telarray",
-                write_files=False,
-            )
-        except ValueError:
-            pars = self.read_mongo_db(
-                self._get_db_name(),
-                names.get_array_element_type_from_name(_array_element_name) + "-design",
-                _model_version,
-                run_location=None,
-                collection_name="configuration_sim_telarray",
-                write_files=False,
-            )
-        DatabaseHandler.sim_telarray_configuration_parameters_cached[_array_elements_cache_key] = (
-            pars
-        )
-        return pars
-
     def _validate_model_input(self, site, array_element_name, model_version):
         """
         Validate input for model parameter queries.
@@ -1221,7 +1166,7 @@ def get_all_versions(

         return DatabaseHandler.model_versions_cached[_cache_key]

-    def _parameter_cache_key(self, site, array_element_name, model_version):
+    def _parameter_cache_key(self, site, array_element_name, model_version, collection=None):
         """
         Create a cache key for the parameter cache dictionaries.

@@ -1233,6 +1178,8 @@ def _parameter_cache_key(self, site, array_element_name, model_version):
             Array element name.
         model_version: str
             Model version.
+        collection: str
+            DB collection name.

         Returns
         -------
@@ -1245,6 +1192,8 @@ def _parameter_cache_key(self, site, array_element_name, model_version):
         if array_element_name:
             parts.append(array_element_name)
         parts.append(model_version)
+        if collection:
+            parts.append(collection)
         return "-".join(parts)

     def _reset_parameter_cache(self, site, array_element_name, model_version):

diff --git a/tests/unit_tests/db/test_db_array_elements.py b/tests/unit_tests/db/test_db_array_elements.py
index 2a1bc5570..dcc693abf 100644
--- a/tests/unit_tests/db/test_db_array_elements.py
+++ b/tests/unit_tests/db/test_db_array_elements.py
@@ -1,14 +1,28 @@
 #!/usr/bin/python3

+import time
+
 import pytest

 from simtools.db import db_array_elements

 def test_get_array_elements(db, model_version):
+
+    time_1 = time.time()
+    db_array_elements.get_array_elements(
+        db.get_collection(db_name=None, collection_name="telescopes"),
+        db.model_version(model_version),
+    )
+    time_2 = time.time()
     available_telescopes = db_array_elements.get_array_elements(
-        db=db, model_version=model_version, collection="telescopes"
+        db.get_collection(db_name=None, collection_name="telescopes"),
+        db.model_version(model_version),
     )
+    time_3 = time.time()
+
+    # check that the second call is much faster than the first one
+    assert (time_2 - time_1) > 0.1 * (time_3 - time_2)

     expected_telescope_names = {
         "LSTN-01": "LSTN-design",
@@ -24,7 +38,8 @@ def test_get_array_elements(db, model_version):
         assert expected_telescope_names[_t] in available_telescopes[_t]

     available_calibration_devices = db_array_elements.get_array_elements(
-        db=db, model_version=model_version, collection="calibration_devices"
+        db.get_collection(db_name=None, collection_name="calibration_devices"),
+        db.model_version(model_version),
     )
     expected_calibration_devices = {
         "ILLN-01": "ILLN-design",
@@ -34,11 +49,10 @@ def test_get_array_elements(db, model_version):
         assert _d in available_calibration_devices
         assert expected_calibration_devices[_d] in available_calibration_devices[_d]

-    with pytest.raises(
-        ValueError, match=r"^No array elements found in DB collection wrong_collection."
-    ):
+    with pytest.raises(ValueError, match=r"^No array elements found in DB collection"):
         db_array_elements.get_array_elements(
-            db=db, model_version=model_version, collection="wrong_collection"
+            db.get_collection(db_name=None, collection_name="wrong_collection"),
+            db.model_version(model_version),
         )

@@ -65,6 +79,10 @@ def test_get_array_element_list_for_db_query(db, model_version):
             "MSTS-301", db=db, model_version=model_version, collection="calibration_devices"
         )

+    assert db_array_elements.get_array_element_list_for_db_query(
+        "LSTN-02", db=db, model_version=model_version, collection="configuration_sim_telarray"
+    ) == ["LSTN-design"]
+

 def test_get_array_elements_of_type(db, model_version):
     available_telescopes = db_array_elements.get_array_elements_of_type(
diff --git a/tests/unit_tests/db/test_db_handler.py b/tests/unit_tests/db/test_db_handler.py
index 84571ced0..3e4a9351f 100644
--- a/tests/unit_tests/db/test_db_handler.py
+++ b/tests/unit_tests/db/test_db_handler.py
@@ -147,10 +147,14 @@ def test_get_derived_values(db, model_version_prod5):

 def test_get_sim_telarray_configuration_parameters(db, model_version):

-    _pars = db.get_sim_telarray_configuration_parameters("North", "LSTN-01", model_version)
+    _pars = db.get_model_parameters(
+        "North", "LSTN-01", model_version, collection="configuration_sim_telarray"
+    )
     assert "min_photoelectrons" in _pars

-    _pars = db.get_sim_telarray_configuration_parameters("North", "LSTN-design", model_version)
+    _pars = db.get_model_parameters(
+        "North", "LSTN-design", model_version, collection="configuration_sim_telarray"
+    )
     assert "min_photoelectrons" in _pars

diff --git a/tests/unit_tests/model/test_model_parameter.py b/tests/unit_tests/model/test_model_parameter.py
index 446b55fb1..0f18682ab 100644
--- a/tests/unit_tests/model/test_model_parameter.py
+++ b/tests/unit_tests/model/test_model_parameter.py
@@ -146,11 +146,11 @@ def test_load_parameters_from_db(telescope_model_lst, mocker):
     telescope_copy = copy.deepcopy(telescope_model_lst)
     mock_db = mocker.patch.object(DatabaseHandler, "get_model_parameters")
     telescope_copy._load_parameters_from_db()
-    mock_db.assert_called_once()
+    assert mock_db.call_count == 2

     telescope_copy.db = None
     telescope_copy._load_parameters_from_db()
-    not mock_db.assert_called_once()
+    assert mock_db.call_count == 2

 def test_extra_labels(telescope_model_lst):

test_get_corsika_telescope_list takes too long
The `test_get_corsika_telescope_list` test takes roughly 20 seconds to run. Profiling it points to excessive time spent initializing the array model, perhaps related to reading from the DB too many times.

```
  ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000   21.427   21.427 /workdir/external/simtools/simtools/model/array_model.py:41(__init__)
        1    0.000    0.000   20.855   20.855 /workdir/external/simtools/simtools/model/array_model.py:69(_initialize)
       60    0.004    0.000   20.773    0.346 /workdir/external/simtools/simtools/model/model_parameter.py:49(__init__)
       58    0.002    0.000   19.823    0.342 /workdir/external/simtools/simtools/model/telescope_model.py:38(__init__)
        1    0.000    0.000    0.994    0.994 /workdir/external/simtools/simtools/model/site_model.py:27(__init__)
       61    0.001    0.000    0.574    0.009 /workdir/external/simtools/simtools/db/db_handler.py:57(__init__)
```

Need to look into it further (in the middle of something else, opening this issue so it is not forgotten).

The `array_model` consist of the site model, the model of all telescopes and of all calibration devices. So this is naturally that all DB calls are initiated from the array_model_class.

The number of calls to the DB are reduced by having the `DatabaseHandler.*cached` dicts, where the idea is to not query the same parameter twice.

Obviously always good to check that this is still working.
The main difference to the old style is that now we call the DB for each telescope separately. In the past we read all of the parameters of all of the telescopes in one query (which is very quick). I suggest to extend the caching functionality to do that as well with the new style. Usually the model parameter version does not change from one telescope to the next (at least for now), so when the first telescope is read, we can fill the cache with the parameters of all telescopes for that same version. The should reduce the DB calls to one in most cases.
I can confirm that when running at DESY this test takes 1 second, while running it at home takes 15-20 seconds.
On GitHub this test is also the slowest at ~50 seconds (see e.g., [this job](https://github.com/gammasim/simtools/actions/runs/10700964938/job/29665863400?pr=1127)). I think that adding another cache level is therefore worth it since it will shorten the running time of the GitHub tests. It's far from urgent though, considering it is just a minute in the worst case scenario.
Started to have a look:

- `test_get_corsika_telescope_list` is the only test for corsika_config.py which actually accesses the database (all other tests are using the fixture `corsika_config_no_db`; I actually introduce at some point a `@pytest.mark.uses_model_database()` marker)
- so no wonder that this is the slowest function in these tests...

Given that we want to test the code and not the database connection, my suggestion is to change also this function and either use the `corsika_config_no_db` or some mocking (aligned with the suggestion in issue #1054). OK?

Additionally:

- we should think about dedicated benchmark tests on accessing the DB for different usage scenarios
- suggest that this would be an integration test and not a unit test, as it tests several modules
I find the comments/suggestions a little bit contradictory. I will write comments below. They are out of order, but I think follow some logic still.

> - suggest that this would be an integration test and not a unit test, as it tests several modules

If we want to change it to be an integration test, then I wouldn't use mocking but actually connect to the DB as expected in integration tests and as we do in others.

> - we should think about dedicated benchmark tests on accessing the DB for different usage scenarios

This test actually accomplished that in a way. It showed that we are accessing the DB for each telescope separately, which is something we can probably easily avoid. I agree that having a dedicated test for accessing the DB is good though, let's try to come up with it after we decide on the structure of the DB.

> Given that we want to test the code and not the database connection, my suggestion is to change also this function and either use the corsika_config_no_db or some mocking (aligned with the suggestion in issue https://github.com/gammasim/simtools/issues/1054). OK?

It's fine to change the unit test to use mocking, but then I would add a separate integration test that does the same, actually using the DB. I think it's important to test this in a real scenario, considering the chain of "events" this test triggers (even though it is just one command).

Oh and I would wait before implementing any changes until we implement the DB restructuring. This problem might resolve itself then because we will probably access the DB once, getting the entries based on the table of production version/parameter versions.
Yes and no:

- independent of any restructuring should we avoid DB calls in unit tests
- we need a test for the DB connections (but not a unit test)

I think both points are not affected by restructuring.
Yes, sorry, I didn't mean that those points would not be valid anymore, just that this specific issue of many unnecessary calls would (hopefully) be resolved. I don't think we should add another cache at the moment (like I proposed above) because it will probably be superfluous once we implement the restructuring.