diff --git a/.run/All unit tests.run.xml b/.run/All unit tests.run.xml
index 95c8e8b28220babaccac81bddf1f6bd1614e6994..ebe148c5b1905281395d2c132cf120d9c339acce 100644
--- a/.run/All unit tests.run.xml	
+++ b/.run/All unit tests.run.xml	
@@ -1,9 +1,24 @@
 <component name="ProjectRunConfigurationManager">
-	<configuration default="false" name="All unit tests" type="CompoundRunConfigurationType">
-		<toRun name="mailbox [ktlintCheck]" type="GradleRunConfiguration" />
-		<toRun name=":mailbox-android:test" type="GradleRunConfiguration" />
-		<toRun name=":mailbox-core:test" type="GradleRunConfiguration" />
-		<toRun name=":mailbox-cli:test" type="GradleRunConfiguration" />
+	<configuration default="false" name="All unit tests" type="GradleRunConfiguration"
+		factoryName="Gradle">
+		<ExternalSystemSettings>
+			<option name="executionName" />
+			<option name="externalProjectPath" value="$PROJECT_DIR$" />
+			<option name="externalSystemIdString" value="GRADLE" />
+			<option name="scriptParameters" value="" />
+			<option name="taskDescriptions">
+				<list />
+			</option>
+			<option name="taskNames">
+				<list>
+					<option value="check" />
+				</list>
+			</option>
+			<option name="vmOptions" value="" />
+		</ExternalSystemSettings>
+		<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+		<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+		<DebugAllEnabled>false</DebugAllEnabled>
 		<method v="2" />
 	</configuration>
 </component>
\ No newline at end of file
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
index 03567871b02ea5362a2947bdf5ffd55f95e527f2..27654404856566c46e62b7e2322f3ff60a982019 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
@@ -1,6 +1,7 @@
 package org.briarproject.mailbox.core.lifecycle;
 
 import org.briarproject.mailbox.core.db.Database;
+import org.briarproject.mailbox.core.db.Transaction;
 import org.briarproject.mailbox.core.system.Wakeful;
 
 import java.util.concurrent.ExecutorService;
@@ -107,6 +108,6 @@ public interface LifecycleManager {
 		 * {@link #waitForDatabase()} returns.
 		 */
 		@Wakeful
-		void onDatabaseOpened();
+		void onDatabaseOpened(Transaction txn);
 	}
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt
index 1ab9b3549ed4d6196c59bee7807a8f02c49d6f72..0435916693ded6383645c9c6ae7554f9ad8e52c3 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt
@@ -80,6 +80,12 @@ internal class LifecycleManagerImpl @Inject constructor(private val db: Database
             val reopened = db.open(this)
             if (reopened) logDuration(LOG, { "Reopening database" }, start)
             else logDuration(LOG, { "Creating database" }, start)
+            // Inform hooks that DB was opened
+            db.write { txn ->
+                for (hook in openDatabaseHooks) {
+                    hook.onDatabaseOpened(txn)
+                }
+            }
             LOG.info("Starting services")
             state.value = STARTING_SERVICES
             dbLatch.countDown()
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt
index 0aaf277b5c9bbe07ca044abfd33c1f842ba585b1..16baaf81c145f8115381e173b9dbd41f38665b41 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt
@@ -6,6 +6,7 @@ import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.server.MailboxPrincipal.ContactPrincipal
 import org.briarproject.mailbox.core.server.MailboxPrincipal.OwnerPrincipal
 import org.briarproject.mailbox.core.server.MailboxPrincipal.SetupPrincipal
+import org.briarproject.mailbox.core.settings.MetadataManager
 import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.RandomIdManager
 import javax.inject.Inject
@@ -15,6 +16,7 @@ import javax.inject.Singleton
 class AuthManager @Inject constructor(
     private val db: Database,
     private val setupManager: SetupManager,
+    private val metadataManager: MetadataManager,
     private val randomIdManager: RandomIdManager,
 ) {
 
@@ -24,7 +26,7 @@ class AuthManager @Inject constructor(
      */
     fun getPrincipal(token: String): MailboxPrincipal? {
         randomIdManager.assertIsRandomId(token)
-        return db.read { txn ->
+        val principal = db.read { txn ->
             val contact = db.getContactWithToken(txn, token)
             when {
                 contact != null -> ContactPrincipal(contact)
@@ -33,6 +35,10 @@ class AuthManager @Inject constructor(
                 else -> null
             }
         }
+        // We register the owner connection here before further call validation.
+        // It can still happen that the owner sends invalid requests, but that's fine here.
+        if (principal is OwnerPrincipal) metadataManager.onOwnerConnected()
+        return principal
     }
 
     /**
@@ -77,6 +83,14 @@ class AuthManager @Inject constructor(
         if (principal !is OwnerPrincipal) throw AuthException()
     }
 
+    /**
+     * @throws [AuthException] when given [principal] is NOT the setup token.
+     */
+    @Throws(AuthException::class)
+    fun assertIsSetup(principal: MailboxPrincipal?) {
+        if (principal !is SetupPrincipal) throw AuthException()
+    }
+
 }
 
 sealed class MailboxPrincipal : Principal {
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt
index 20ecdee08460628233187c39fc9244cb35b8f83d..aa83f0172ae07e6d95200a98135538347f0a4cf8 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt
@@ -4,7 +4,6 @@ import io.ktor.application.Application
 import io.ktor.application.ApplicationCall
 import io.ktor.application.call
 import io.ktor.auth.authenticate
-import io.ktor.auth.principal
 import io.ktor.features.BadRequestException
 import io.ktor.features.MissingRequestParameterException
 import io.ktor.http.ContentType
@@ -22,14 +21,16 @@ import io.ktor.routing.routing
 import io.ktor.util.getOrFail
 import org.briarproject.mailbox.core.contacts.ContactsManager
 import org.briarproject.mailbox.core.files.FileManager
-import org.briarproject.mailbox.core.setup.SetupManager
+import org.briarproject.mailbox.core.settings.MetadataRouteManager
+import org.briarproject.mailbox.core.setup.SetupRouteManager
 import org.briarproject.mailbox.core.setup.WipeManager
 import org.briarproject.mailbox.core.system.InvalidIdException
 
 internal const val V = "/" // TODO set to "/v1" for release
 
 internal fun Application.configureBasicApi(
-    setupManager: SetupManager,
+    metadataRouteManager: MetadataRouteManager,
+    setupRouteManager: SetupRouteManager,
     wipeManager: WipeManager,
 ) = routing {
     route(V) {
@@ -43,9 +44,7 @@ internal fun Application.configureBasicApi(
         authenticate {
             get("/status") {
                 call.handle {
-                    if (call.principal<MailboxPrincipal>() !is MailboxPrincipal.OwnerPrincipal)
-                        throw AuthException()
-                    call.respond(HttpStatusCode.OK)
+                    metadataRouteManager.onStatusRequest(call)
                 }
             }
             delete {
@@ -55,7 +54,7 @@ internal fun Application.configureBasicApi(
             }
             put("/setup") {
                 call.handle {
-                    setupManager.onSetupRequest(call)
+                    setupRouteManager.onSetupRequest(call)
                 }
             }
         }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
index 96889cbbb6a9871806910feeffa4602beb9cdf6e..34edb4591bf784a3d54482536bd1fbe2ff88db4b 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
@@ -11,7 +11,8 @@ import org.briarproject.mailbox.core.contacts.ContactsManager
 import org.briarproject.mailbox.core.files.FileManager
 import org.briarproject.mailbox.core.lifecycle.Service
 import org.briarproject.mailbox.core.server.WebServerManager.Companion.PORT
-import org.briarproject.mailbox.core.setup.SetupManager
+import org.briarproject.mailbox.core.settings.MetadataRouteManager
+import org.briarproject.mailbox.core.setup.SetupRouteManager
 import org.briarproject.mailbox.core.setup.WipeManager
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory.getLogger
@@ -27,7 +28,8 @@ interface WebServerManager : Service {
 @Singleton
 internal class WebServerManagerImpl @Inject constructor(
     private val authManager: AuthManager,
-    private val setupManager: SetupManager,
+    private val metadataRouteManager: MetadataRouteManager,
+    private val setupRouteManager: SetupRouteManager,
     private val contactsManager: ContactsManager,
     private val fileManager: FileManager,
     private val wipeManager: WipeManager,
@@ -52,7 +54,7 @@ internal class WebServerManagerImpl @Inject constructor(
             install(ContentNegotiation) {
                 jackson()
             }
-            configureBasicApi(setupManager, wipeManager)
+            configureBasicApi(metadataRouteManager, setupRouteManager, wipeManager)
             configureContactApi(contactsManager)
             configureFilesApi(fileManager)
         }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/MetadataManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/MetadataManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b24da7ed4fd769a0478136b2ba74235e03346686
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/MetadataManager.kt
@@ -0,0 +1,73 @@
+package org.briarproject.mailbox.core.settings
+
+import io.ktor.application.ApplicationCall
+import io.ktor.auth.principal
+import io.ktor.http.HttpStatusCode
+import io.ktor.response.respond
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.briarproject.mailbox.core.db.DbException
+import org.briarproject.mailbox.core.db.Transaction
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager.OpenDatabaseHook
+import org.briarproject.mailbox.core.server.AuthException
+import org.briarproject.mailbox.core.server.AuthManager
+import javax.inject.Inject
+
+interface MetadataManager : OpenDatabaseHook {
+
+    /**
+     * Call this after the owner authenticated.
+     * It stores the current timestamp in settings.
+     */
+    @Throws(DbException::class)
+    fun onOwnerConnected()
+
+    /**
+     * The epoch timestamp in milliseconds when we saw the last owner connection.
+     * Attention: Only access after [LifecycleManager] has finished starting.
+     * Will be `0` before or when owner has never connected.
+     */
+    val ownerConnectionTime: StateFlow<Long>
+
+}
+
+private const val SETTINGS_NAMESPACE_OWNER_METADATA = "ownerMetadata"
+private const val SETTINGS_LAST_CONNECTION_TIME = "lastConnectionTime"
+
+class MetadataManagerImpl @Inject constructor(
+    private val settingsManager: SettingsManager,
+) : MetadataManager {
+
+    private val _ownerConnectionTime = MutableStateFlow(0L)
+    override val ownerConnectionTime: StateFlow<Long> = _ownerConnectionTime
+
+    override fun onDatabaseOpened(txn: Transaction) {
+        val s = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER_METADATA)
+        _ownerConnectionTime.value = s.getLong(SETTINGS_LAST_CONNECTION_TIME, 0L)
+    }
+
+    @Throws(DbException::class)
+    override fun onOwnerConnected() {
+        val timestamp = System.currentTimeMillis()
+        val s = Settings().apply {
+            putLong(SETTINGS_LAST_CONNECTION_TIME, timestamp)
+        }
+        settingsManager.mergeSettings(s, SETTINGS_NAMESPACE_OWNER_METADATA)
+        _ownerConnectionTime.value = timestamp
+    }
+
+}
+
+class MetadataRouteManager @Inject constructor(
+    private val authManager: AuthManager,
+) {
+    /**
+     * GET /status
+     */
+    @Throws(AuthException::class)
+    suspend fun onStatusRequest(call: ApplicationCall) {
+        authManager.assertIsOwner(call.principal())
+        call.respond(HttpStatusCode.OK)
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
index 59a199d2f3eb50c02ec1edc9614eb6154cbc54ee..830e05f05a0d10646100c5b3be67a3dc0bf1cdc4 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
@@ -4,10 +4,9 @@ import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.db.DbException
 import org.briarproject.mailbox.core.db.Transaction
 import javax.annotation.concurrent.Immutable
-import javax.inject.Inject
 
 @Immutable
-internal class SettingsManagerImpl @Inject constructor(private val db: Database) : SettingsManager {
+internal class SettingsManagerImpl(private val db: Database) : SettingsManager {
 
     @Throws(DbException::class)
     override fun getSettings(namespace: String): Settings {
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt
index 75490e09ff388f8b27c6bd5873d244c7cdbea4fd..e936c6d823455c5c709794a0a4f824be531979d5 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt
@@ -3,11 +3,25 @@ package org.briarproject.mailbox.core.settings
 import dagger.Module
 import dagger.Provides
 import org.briarproject.mailbox.core.db.Database
+import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import javax.inject.Singleton
 
 @Module
 class SettingsModule {
     @Provides
+    @Singleton
     fun provideSettingsManager(db: Database): SettingsManager {
         return SettingsManagerImpl(db)
     }
+
+    @Provides
+    @Singleton
+    fun provideMetadataManager(
+        metadataManagerImpl: MetadataManagerImpl,
+        lifecycleManager: LifecycleManager,
+    ): MetadataManager {
+        return metadataManagerImpl.also {
+            lifecycleManager.registerOpenDatabaseHook(it)
+        }
+    }
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
index eb07a5ed5186baf822b8bad58cff56273520a6df..052ec7880077eff1fa9e809ae0fe9fbb9447c139 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
@@ -7,8 +7,7 @@ import io.ktor.response.respond
 import org.briarproject.mailbox.core.db.DbException
 import org.briarproject.mailbox.core.db.Transaction
 import org.briarproject.mailbox.core.server.AuthException
-import org.briarproject.mailbox.core.server.MailboxPrincipal
-import org.briarproject.mailbox.core.server.MailboxPrincipal.SetupPrincipal
+import org.briarproject.mailbox.core.server.AuthManager
 import org.briarproject.mailbox.core.settings.Settings
 import org.briarproject.mailbox.core.settings.SettingsManager
 import org.briarproject.mailbox.core.system.RandomIdManager
@@ -33,24 +32,6 @@ class SetupManager @Inject constructor(
         settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
     }
 
-    /**
-     * Handler for `PUT /setup` API endpoint.
-     *
-     * Wipes setup token and responds with new owner token and 201 status code.
-     */
-    @Throws(AuthException::class)
-    suspend fun onSetupRequest(call: ApplicationCall) {
-        val principal: MailboxPrincipal? = call.principal()
-        if (principal !is SetupPrincipal) throw AuthException()
-
-        // set new owner token and clear single-use setup token
-        val ownerToken = randomIdManager.getNewRandomId()
-        setToken(null, ownerToken)
-        val response = SetupResponse(ownerToken)
-
-        call.respond(HttpStatusCode.Created, response)
-    }
-
     /**
      * Visible for testing, consider private.
      */
@@ -77,4 +58,27 @@ class SetupManager @Inject constructor(
 
 }
 
+class SetupRouteManager @Inject constructor(
+    private val authManager: AuthManager,
+    private val setupManager: SetupManager,
+    private val randomIdManager: RandomIdManager,
+) {
+    /**
+     * Handler for `PUT /setup` API endpoint.
+     *
+     * Wipes setup token and responds with new owner token and 201 status code.
+     */
+    @Throws(AuthException::class)
+    suspend fun onSetupRequest(call: ApplicationCall) {
+        authManager.assertIsSetup(call.principal())
+
+        // set new owner token and clear single-use setup token
+        val ownerToken = randomIdManager.getNewRandomId()
+        setupManager.setToken(null, ownerToken)
+        val response = SetupResponse(ownerToken)
+
+        call.respond(HttpStatusCode.Created, response)
+    }
+}
+
 internal data class SetupResponse(val token: String)
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
index 6eeff9ca8f6c74f13c17479c5f3e0fc58c656c81..539a39dd52a7eb10a632275c5145fc22fc01ef66 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
@@ -5,6 +5,7 @@ import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.files.FileManager
 import org.briarproject.mailbox.core.files.FileProvider
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.core.settings.MetadataManager
 import org.briarproject.mailbox.core.settings.SettingsManager
 import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.RandomIdManager
@@ -25,4 +26,5 @@ interface TestComponent {
     fun getDatabase(): Database
     fun getRandomIdManager(): RandomIdManager
     fun getFileProvider(): FileProvider
+    fun getMetadataManager(): MetadataManager
 }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt
index 90b367b0fc48784c38397a24d15bb0b30b2adc8b..716de09192d73f785cc575c5e23229d8e61e0559 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt
@@ -15,6 +15,8 @@ import org.briarproject.mailbox.core.util.IoUtils
 import java.io.File
 import kotlin.random.Random
 import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
 
 object TestUtils {
 
@@ -79,4 +81,12 @@ object TestUtils {
         assertEquals(expected, actual)
     }
 
+    fun assertTimestampRecent(timestamp: Long) {
+        assertNotEquals(0, timestamp)
+        assertTrue(
+            System.currentTimeMillis() - timestamp < 1000,
+            "Timestamp is ${System.currentTimeMillis() - timestamp}ms old."
+        )
+    }
+
 }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt
index bb11417d6cd8180373c0f2acbf8cf2e3410a4e54..cc606b857fed7af0c3e6777b409b02cdd4ebf370 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt
@@ -15,8 +15,8 @@ import io.ktor.http.HttpStatusCode.Companion.Unauthorized
 import io.ktor.http.contentType
 import kotlinx.coroutines.runBlocking
 import org.briarproject.mailbox.core.TestUtils.assertJson
+import org.briarproject.mailbox.core.TestUtils.assertTimestampRecent
 import org.briarproject.mailbox.core.TestUtils.getNewRandomContact
-import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.server.IntegrationTest
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
@@ -25,9 +25,6 @@ import kotlin.test.assertEquals
 
 class ContactsManagerIntegrationTest : IntegrationTest() {
 
-    val db: Database
-        get() = testComponent.getDatabase()
-
     @BeforeEach
     fun initDb() {
         addOwnerToken()
@@ -37,15 +34,20 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
     fun clearDb() {
         db.write { txn ->
             db.clearDatabase(txn)
+            // clears [metadataManager.ownerConnectionTime]
+            metadataManager.onDatabaseOpened(txn)
         }
     }
 
     @Test
     fun `get contacts is initially empty`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val response: HttpResponse = httpClient.get("$baseUrl/contacts") {
             authenticateWithToken(ownerToken)
         }
         assertJson("""{ "contacts": [ ] }""", response)
+
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
     }
 
     @Test
@@ -60,12 +62,14 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `get contacts rejects unauthorized for contacts`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         addContact(contact1)
         addContact(contact2)
         val response: HttpResponse = httpClient.get("$baseUrl/contacts") {
             authenticateWithToken(contact1.token)
         }
         assertEquals(Unauthorized, response.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
     }
 
     @Test
@@ -108,6 +112,7 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `owner can add contacts`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val c1 = getNewRandomContact(1).also { addContact(it) }
         val c2 = getNewRandomContact(2).also { addContact(it) }
         val c3 = getNewRandomContact(3)
@@ -119,6 +124,8 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
         }
         assertEquals(Created, response1.status)
 
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
+
         val response2: HttpResponse = httpClient.get("$baseUrl/contacts") {
             authenticateWithToken(ownerToken)
         }
@@ -154,6 +161,7 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `owner can remove contacts`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         addContact(getNewRandomContact(1))
         addContact(getNewRandomContact(2))
 
@@ -162,6 +170,8 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
         }
         assertEquals(OK, response1.status)
 
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
+
         val response2: HttpResponse = httpClient.get("$baseUrl/contacts") {
             authenticateWithToken(ownerToken)
         }
@@ -170,6 +180,7 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `contact cannot remove contacts`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         addContact(contact1)
         addContact(contact2)
 
@@ -177,6 +188,7 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
             authenticateWithToken(contact2.token)
         }
         assertEquals(Unauthorized, response1.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
 
         val response2: HttpResponse = httpClient.get("$baseUrl/contacts") {
             authenticateWithToken(ownerToken)
@@ -207,6 +219,7 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `removing non-existent contacts fails gracefully`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         addContact(getNewRandomContact(1))
         addContact(getNewRandomContact(2))
 
@@ -215,6 +228,9 @@ class ContactsManagerIntegrationTest : IntegrationTest() {
         }
         assertEquals(NotFound, response1.status)
 
+        // still registers owner connection
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
+
         val response2: HttpResponse = httpClient.get("$baseUrl/contacts") {
             authenticateWithToken(ownerToken)
         }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt
index 8c1e09017b7bc922f9741a52a5c08b569c00df33..8b838ef95e9252d5df850fe8b8b865a2ed666849 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/files/FileManagerIntegrationTest.kt
@@ -9,6 +9,7 @@ import io.ktor.client.statement.readBytes
 import io.ktor.client.statement.readText
 import io.ktor.http.HttpStatusCode
 import kotlinx.coroutines.runBlocking
+import org.briarproject.mailbox.core.TestUtils.assertTimestampRecent
 import org.briarproject.mailbox.core.TestUtils.getNewRandomId
 import org.briarproject.mailbox.core.server.IntegrationTest
 import org.junit.jupiter.api.AfterEach
@@ -32,6 +33,11 @@ class FileManagerIntegrationTest : IntegrationTest() {
     @AfterEach
     fun cleanUp() {
         testComponent.getFileManager().deleteAllFiles()
+        db.write { txn ->
+            db.clearDatabase(txn)
+            // clears [metadataManager.ownerConnectionTime]
+            metadataManager.onDatabaseOpened(txn)
+        }
     }
 
     @Test
@@ -64,12 +70,15 @@ class FileManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `post new file, list, download and delete it`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         // owner uploads the file
         val response: HttpResponse = httpClient.post("$baseUrl/files/${contact1.inboxId}") {
             authenticateWithToken(ownerToken)
             body = bytes
         }
         assertEquals(HttpStatusCode.OK, response.status)
+        // owner connection got registered
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
 
         // contact can list the file
         val listResponse: HttpResponse = httpClient.get("$baseUrl/files/${contact1.inboxId}") {
@@ -105,10 +114,12 @@ class FileManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `list files rejects wrong token`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val response: HttpResponse = httpClient.get("$baseUrl/files/${getNewRandomId()}") {
             authenticateWithToken(token)
         }
         assertEquals(HttpStatusCode.Unauthorized, response.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
 
         // upload a real file
         val postResponse: HttpResponse = httpClient.post("$baseUrl/files/${contact1.inboxId}") {
@@ -148,11 +159,14 @@ class FileManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `list files gives empty response for empty folder`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val response: HttpResponse = httpClient.get("$baseUrl/files/${contact1.outboxId}") {
             authenticateWithToken(ownerToken)
         }
         assertEquals(HttpStatusCode.OK, response.status)
         assertEquals("""{"files":[]}""", response.readText())
+        // owner connection got registered
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
     }
 
     @Test
@@ -200,6 +214,17 @@ class FileManagerIntegrationTest : IntegrationTest() {
         assertEquals(HttpStatusCode.NotFound, response.status)
     }
 
+    @Test
+    fun `get file for contact does not update owner timestamp`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
+        val id = getNewRandomId()
+        val response: HttpResponse = httpClient.get("$baseUrl/files/${contact1.inboxId}/$id") {
+            authenticateWithToken(contact1.token)
+        }
+        assertEquals(HttpStatusCode.NotFound, response.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
+    }
+
     @Test
     fun `delete file rejects wrong token`(): Unit = runBlocking {
         val response: HttpResponse =
@@ -238,32 +263,52 @@ class FileManagerIntegrationTest : IntegrationTest() {
 
     @Test
     fun `delete file gives 404 response for unknown file`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val id = getNewRandomId()
         val response: HttpResponse = httpClient.delete("$baseUrl/files/${contact1.outboxId}/$id") {
             authenticateWithToken(ownerToken)
         }
         assertEquals(HttpStatusCode.NotFound, response.status)
+        // owner connection still got registered
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
+    }
+
+    @Test
+    fun `delete file for contact does not update owner timestamp`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
+        val id = getNewRandomId()
+        val response: HttpResponse = httpClient.delete("$baseUrl/files/${contact1.inboxId}/$id") {
+            authenticateWithToken(contact1.token)
+        }
+        assertEquals(HttpStatusCode.NotFound, response.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
     }
 
     @Test
     fun `list folders rejects contacts`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val response: HttpResponse = httpClient.get("$baseUrl/folders") {
             authenticateWithToken(contact1.token)
         }
         assertEquals(HttpStatusCode.Unauthorized, response.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
     }
 
     @Test
     fun `list folders allows owner, returns empty result`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         val response: HttpResponse = httpClient.get("$baseUrl/folders") {
             authenticateWithToken(ownerToken)
         }
         assertEquals(HttpStatusCode.OK, response.status)
         assertEquals("""{"folders":[]}""", response.readText())
+        // owner connection got registered
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
     }
 
     @Test
     fun `list folders returns more than a single folder`(): Unit = runBlocking {
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
         // contact1 uploads a file
         val response1: HttpResponse = httpClient.post("$baseUrl/files/${contact1.outboxId}") {
             authenticateWithToken(contact1.token)
@@ -277,6 +322,7 @@ class FileManagerIntegrationTest : IntegrationTest() {
             body = bytes
         }
         assertEquals(HttpStatusCode.OK, response2.status)
+        assertEquals(0L, metadataManager.ownerConnectionTime.value)
 
         // owner now sees both contacts' outboxes in folders list
         val folderListResponse: FolderListResponse = httpClient.get("$baseUrl/folders") {
@@ -284,5 +330,7 @@ class FileManagerIntegrationTest : IntegrationTest() {
         }
         val folderList = setOf(FolderResponse(contact1.outboxId), FolderResponse(contact2.outboxId))
         assertEquals(folderList, folderListResponse.folders.toSet())
+        // owner connection got registered
+        assertTimestampRecent(metadataManager.ownerConnectionTime.value)
     }
 }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt
index 4501fcf4ffa037ae38d1bbdac0ca243f5151c1d9..7de249672059ee6714428009534880e16c256bea 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt
@@ -1,6 +1,8 @@
 package org.briarproject.mailbox.core.server
 
+import io.mockk.Runs
 import io.mockk.every
+import io.mockk.just
 import io.mockk.mockk
 import org.briarproject.mailbox.core.TestUtils.everyRead
 import org.briarproject.mailbox.core.TestUtils.getNewRandomContact
@@ -8,6 +10,7 @@ import org.briarproject.mailbox.core.TestUtils.getNewRandomId
 import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.server.MailboxPrincipal.OwnerPrincipal
 import org.briarproject.mailbox.core.server.MailboxPrincipal.SetupPrincipal
+import org.briarproject.mailbox.core.settings.MetadataManager
 import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.InvalidIdException
 import org.briarproject.mailbox.core.system.RandomIdManager
@@ -22,9 +25,10 @@ class AuthManagerTest {
 
     private val db: Database = mockk()
     private val setupManager: SetupManager = mockk()
+    private val metadataManager: MetadataManager = mockk()
     private val randomIdManager = RandomIdManager()
 
-    private val authManager = AuthManager(db, setupManager, randomIdManager)
+    private val authManager = AuthManager(db, setupManager, metadataManager, randomIdManager)
 
     private val id = getNewRandomId()
     private val otherId = getNewRandomId()
@@ -53,6 +57,7 @@ class AuthManagerTest {
             every { db.getContactWithToken(txn, id) } returns null
             every { setupManager.getOwnerToken(txn) } returns id
         }
+        every { metadataManager.onOwnerConnected() } just Runs
 
         assertEquals(OwnerPrincipal, authManager.getPrincipal(id))
     }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
index 54752fac5091fd5ca352b3aa75a29efd6010471f..6cc47fc86c90a88124f59d10c1183298e42b0a8b 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
@@ -25,7 +25,9 @@ import java.io.File
 abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
 
     protected lateinit var testComponent: TestComponent
+    protected val db by lazy { testComponent.getDatabase() }
     private val lifecycleManager by lazy { testComponent.getLifecycleManager() }
+    protected val metadataManager by lazy { testComponent.getMetadataManager() }
     protected val httpClient = HttpClient(CIO) {
         expectSuccess = false // prevents exceptions on non-success responses
         if (installJsonFeature) {
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/StatusIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/settings/MetadataRouteManagerTest.kt
similarity index 90%
rename from mailbox-core/src/test/java/org/briarproject/mailbox/core/server/StatusIntegrationTest.kt
rename to mailbox-core/src/test/java/org/briarproject/mailbox/core/settings/MetadataRouteManagerTest.kt
index 2439ef58a6690482d36b4ed24bd65a94e8a5b9a4..d758217f86d36d3581d142ea09cfc87988afe4ef 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/StatusIntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/settings/MetadataRouteManagerTest.kt
@@ -1,15 +1,16 @@
-package org.briarproject.mailbox.core.server
+package org.briarproject.mailbox.core.settings
 
 import io.ktor.client.request.get
 import io.ktor.client.statement.HttpResponse
 import io.ktor.client.statement.readText
 import io.ktor.http.HttpStatusCode
 import kotlinx.coroutines.runBlocking
+import org.briarproject.mailbox.core.server.IntegrationTest
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import kotlin.test.assertEquals
 
-class StatusIntegrationTest : IntegrationTest() {
+class MetadataRouteManagerTest : IntegrationTest() {
 
     @BeforeEach
     fun initDb() {
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
index ca509825b8d0686e90acfce4ce91be2f601bc21e..9f28cace4fb3edff0e37cce31ccb319a9c9ea7cc 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
@@ -13,13 +13,16 @@ import kotlin.test.assertNull
 
 class SetupManagerTest : IntegrationTest() {
 
-    private val db by lazy { testComponent.getDatabase() }
     private val setupManager by lazy { testComponent.getSetupManager() }
 
     @AfterEach
     fun resetToken() {
-        // re-set both token to not interfere with other tests
-        setupManager.setToken(null, null)
+        db.write { txn ->
+            // re-set both token to not interfere with other tests
+            db.clearDatabase(txn)
+            // clears [metadataManager.ownerConnectionTime]
+            metadataManager.onDatabaseOpened(txn)
+        }
     }
 
     @Test
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeManagerTest.kt
index a4d847aab563c9efce1f22ead183da3ad3d6b634..b7c606099c6626550ebb86c3e5187b336011c0aa 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeManagerTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeManagerTest.kt
@@ -14,8 +14,6 @@ import kotlin.test.assertTrue
 
 class WipeManagerTest : IntegrationTest() {
 
-    private val db by lazy { testComponent.getDatabase() }
-
     @Test
     fun `wipe request rejects non-owners`() = runBlocking {
         addOwnerToken()