diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 889917c7bb..e72e2907a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3800,6 +3800,15 @@ class Account( marmotGroupList.addMessage(groupId, note) } + // A disappearing message that is gone from disk but still on screen + // has not disappeared. Drop it from the conversation as it expires, + // rather than waiting for the next read to omit it. + marmotManager.onMessagesExpired = { groupId, expiredIds -> + expiredIds.forEach { id -> + cache.getNoteIfExists(id)?.let { marmotGroupList.removeMessage(groupId, it) } + } + } + scope.launch(Dispatchers.IO) { marmotManager.restoreAll() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt index 437fc0cbc6..ab59b622d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.marmot import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -111,7 +112,7 @@ class AndroidMarmotMessageStore( override suspend fun delete(nostrGroupId: String) { withContext(Dispatchers.IO) { writeMutex.withLock { - for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId))) { + for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId), expiriesFile(nostrGroupId))) { if (file.exists() && !file.delete()) { Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" } } @@ -166,6 +167,82 @@ class AndroidMarmotMessageStore( } } + private fun expiriesFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "expiries") + + /** + * When a message stops being displayable, for a group that expires them. + * + * Encrypted like the messages: an expiry names an inner event id and says + * roughly when it was sent, which is conversation metadata. + * + * First write wins. The expiry is pinned to the retention of the message's + * own source epoch, so re-persisting the same message after a restart — + * which happens, because the ratchet rewinds and relays replay — must not + * re-time it under whatever the setting has since become. + */ + override suspend fun recordExpiry( + nostrGroupId: String, + innerEventId: String, + expiresAtSecs: Long, + ) = withContext(Dispatchers.IO) { + writeMutex.withLock { + try { + val existing = readAllFrom(expiriesFile(nostrGroupId)).toMutableList() + if (existing.any { it.substringBefore(' ') == innerEventId }) return@withLock + existing.add("$innerEventId $expiresAtSecs") + writeAllTo(expiriesFile(nostrGroupId), existing) + } catch (e: Exception) { + Log.e(TAG, "recordExpiry($nostrGroupId) FAILED: ${e.message}", e) + } + } + } + + override suspend fun loadExpiries(nostrGroupId: String): Map = + withContext(Dispatchers.IO) { + try { + readAllFrom(expiriesFile(nostrGroupId)) + .mapNotNull { line -> + val parts = line.trim().split(' ') + if (parts.size != 2) return@mapNotNull null + val at = parts[1].toLongOrNull() ?: return@mapNotNull null + parts[0] to at + }.toMap() + } catch (e: Exception) { + Log.e(TAG, "loadExpiries($nostrGroupId) FAILED: ${e.message}", e) + emptyMap() + } + } + + /** + * Delete messages and forget their expiries, rewriting both logs. + * + * A rewrite rather than a tombstone: the point of a disappearing message + * is that the plaintext is gone from disk, and this store holds the only + * copy — the ratchet moved past the ciphertext it came from long ago. + */ + override suspend fun removeMessages( + nostrGroupId: String, + innerEventIds: Set, + ) = withContext(Dispatchers.IO) { + if (innerEventIds.isEmpty()) return@withContext + writeMutex.withLock { + try { + val kept = + readAll(nostrGroupId).filter { json -> + val id = Event.fromJsonOrNull(json)?.id + id == null || id !in innerEventIds + } + writeAll(nostrGroupId, kept) + + val keptExpiries = + readAllFrom(expiriesFile(nostrGroupId)).filter { it.substringBefore(' ') !in innerEventIds } + writeAllTo(expiriesFile(nostrGroupId), keptExpiries) + } catch (e: Exception) { + Log.e(TAG, "removeMessages($nostrGroupId) FAILED: ${e.message}", e) + } + } + } + private fun snapshotFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "snapshot") /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 15bc34c0c7..0f9d5b7d15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -731,6 +731,11 @@ class GroupEventHandler( // `MarmotGroupList.isDisplayableFeedMessage`. account.marmotGroupList.addMessage(result.groupId, innerNote) + // Traffic is the natural clock for disappearing messages: a + // group being read is a group whose expired messages should + // already be gone. + manager.pruneExpiredMessages(result.groupId) + // Persist the decrypted plaintext so the message // survives an app restart. Marmot/MLS application // messages cannot be re-decrypted once the ratchet diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt index f76eb16ad9..5d3126e21a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -153,6 +154,7 @@ class FileMarmotMessageStore( file(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group messages") epochFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group message epochs") snapshotFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group system-row baseline") + expiryFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group message expiries") } private fun snapshotFile(id: String) = File(dir, "$id.snapshot") @@ -167,6 +169,56 @@ class FileMarmotMessageStore( override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshotFile(nostrGroupId).takeIf { it.exists() }?.readText() + private fun expiryFile(id: String) = File(dir, "$id.expiries") + + /** + * First write wins: an expiry is pinned to the retention of the message's + * own source epoch, so re-persisting the same message after a replay must + * not re-time it under a setting that has since changed. + */ + override suspend fun recordExpiry( + nostrGroupId: String, + innerEventId: String, + expiresAtSecs: Long, + ) { + val target = expiryFile(nostrGroupId) + if (target.exists() && target.readLines().any { it.substringBefore(' ') == innerEventId }) return + SecureFileIO.appendText(target, "$innerEventId $expiresAtSecs\n") + } + + override suspend fun loadExpiries(nostrGroupId: String): Map = + expiryFile(nostrGroupId) + .takeIf { it.exists() } + ?.readLines() + ?.mapNotNull { line -> + val parts = line.trim().split(' ') + if (parts.size != 2) return@mapNotNull null + val at = parts[1].toLongOrNull() ?: return@mapNotNull null + parts[0] to at + }?.toMap() + ?: emptyMap() + + /** Rewrites both logs: a disappearing message has to actually leave the disk. */ + override suspend fun removeMessages( + nostrGroupId: String, + innerEventIds: Set, + ) { + if (innerEventIds.isEmpty()) return + val target = file(nostrGroupId) + if (target.exists()) { + val kept = + target.readLines().filter { line -> + line.isNotBlank() && Event.fromJsonOrNull(line)?.id !in innerEventIds + } + SecureFileIO.writeBytesAtomic(target, (kept.joinToString("\n") + if (kept.isEmpty()) "" else "\n").encodeToByteArray()) + } + val expiries = expiryFile(nostrGroupId) + if (expiries.exists()) { + val kept = expiries.readLines().filter { it.isNotBlank() && it.substringBefore(' ') !in innerEventIds } + SecureFileIO.writeBytesAtomic(expiries, (kept.joinToString("\n") + if (kept.isEmpty()) "" else "\n").encodeToByteArray()) + } + } + private fun epochFile(id: String) = File(dir, "$id.epochs") override suspend fun recordEpoch( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt index e875e4aea0..7cf6a61e5a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt @@ -168,6 +168,9 @@ private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngest // MLS ratchets once we decrypt; future reads of the same ciphertext // would fail — persist the plaintext now so restarts/replays see it. persistDecryptedMessage(result.groupId, result.innerEventJson, result.epoch) + // Traffic is the natural clock for expiry: a group that is being + // read is a group whose expired messages should already be gone. + pruneExpiredMessages(result.groupId) MarmotIngestResult.Message(result) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 17de8a23ac..705889a667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -1094,9 +1094,14 @@ class MarmotManager( ) { try { messageStore?.appendMessage(nostrGroupId, innerEventJson) - if (epoch != null) { - Event.fromJsonOrNull(innerEventJson)?.let { messageStore?.recordEpoch(nostrGroupId, it.id, epoch) } + val parsed = Event.fromJsonOrNull(innerEventJson) + if (epoch != null && parsed != null) { + messageStore?.recordEpoch(nostrGroupId, parsed.id, epoch) } + // Pinned here, at the moment the message enters the log, because + // this is the last point at which the retention of its delivering + // epoch is still the group's current retention. + parsed?.let { pinExpiry(nostrGroupId, it) } } catch (e: Exception) { Log.w("MarmotManager", "Failed to persist Marmot message for $nostrGroupId", e) } @@ -1238,12 +1243,89 @@ class MarmotManager( */ suspend fun loadStoredMessages(nostrGroupId: HexKey): List = try { - messageStore?.loadMessages(nostrGroupId) ?: emptyList() + val store = messageStore ?: return emptyList() + // Prune before reading, so a restart cannot show a message that + // expired while the app was closed. Filtering the read alone would + // leave it on disk, and disk is the whole point: the ratchet moved + // past the ciphertext long ago, so this store is the only copy. + pruneExpiredMessages(nostrGroupId) + store.loadMessages(nostrGroupId) } catch (e: Exception) { Log.w("MarmotManager", "Failed to load persisted messages for $nostrGroupId", e) emptyList() } + /** + * The group's disappearing-message duration in seconds, or 0 when off. + * + * Read from the current profile's `0x8005` component, falling back to a + * legacy group's `0xF2EE` field — a legacy group carries the same setting + * in the monolithic blob, and reading only the component would silently + * treat every legacy group as having no expiry at all. + */ + fun retentionSeconds(nostrGroupId: HexKey): Long { + val fromComponent = groupState(nostrGroupId)?.retention?.disappearingMessageSecs + if (fromComponent != null) return fromComponent.toLong() + return groupMetadata(nostrGroupId)?.disappearingMessageSecs?.toLong() ?: 0L + } + + /** + * Pin when [innerEvent] stops being displayable, if this group expires + * messages at all. + * + * Pinned at persist time and never recomputed, because the component says + * a message keeps the retention of its OWN source epoch: a later change to + * the setting must not shorten, extend, or restore the expiry of a message + * that already exists. + * + * The base is the sender's own `created_at`, which the component + * acknowledges is only as trustworthy as the MLS-authenticated sender — + * expiry is advisory, not a deletion guarantee against a hostile member. + */ + private suspend fun pinExpiry( + nostrGroupId: HexKey, + innerEvent: Event, + ) { + val seconds = retentionSeconds(nostrGroupId) + if (seconds <= 0L) return + try { + messageStore?.recordExpiry(nostrGroupId, innerEvent.id, innerEvent.createdAt + seconds) + } catch (e: Exception) { + Log.w("MarmotManager", "Failed to pin expiry for ${innerEvent.id} in $nostrGroupId", e) + } + } + + /** + * Delete every message whose pinned expiry has passed. + * + * @return the inner event ids that were removed, so a front end can drop + * them from a conversation it is already showing rather than waiting for + * the next read. + */ + suspend fun pruneExpiredMessages( + nostrGroupId: HexKey, + nowSecs: Long = TimeUtils.now(), + ): Set { + val store = messageStore ?: return emptySet() + return try { + val expired = store.loadExpiries(nostrGroupId).filterValues { it <= nowSecs }.keys + if (expired.isEmpty()) return emptySet() + store.removeMessages(nostrGroupId, expired) + Log.d("MarmotManager") { "expired ${expired.size} message(s) in ${nostrGroupId.take(8)}…" } + onMessagesExpired?.invoke(nostrGroupId, expired) + expired + } catch (e: Exception) { + Log.w("MarmotManager", "Failed to expire messages for $nostrGroupId", e) + emptySet() + } + } + + /** + * Called for every message this client expires, so a front end can drop it + * from a conversation that is already on screen. + */ + var onMessagesExpired: ((nostrGroupId: HexKey, innerEventIds: Set) -> Unit)? = null + /** * Remove a member from a group. * Returns the commit GroupEvent to publish. diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotRetentionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotRetentionTest.kt new file mode 100644 index 0000000000..cf5645e11c --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotRetentionTest.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Disappearing messages — `marmot.group.message-retention.v1`, component + * `0x8005`. + * + * The component's own rules are what these assert, and two of them are easy to + * get wrong in ways nobody notices until a message that should be gone is + * still there: + * + * - every message pins the retention of its OWN source epoch, so changing the + * setting later must not shorten, extend, or restore an existing message's + * expiry; and + * - a retry of the same MLS message reuses the same pinned value, which + * matters because the ratchet rewinds on restart and relays replay. + * + * Expiry is advisory by design: the duration is authenticated but the base is + * the sender's own `created_at`, so it inherits the trust already placed in an + * MLS-authenticated sender and is not a guarantee against a hostile one. + */ +class MarmotRetentionTest { + private val nostrGroupId = "e".repeat(64) + + private class Fixture { + val signer = NostrSignerInternal(KeyPair()) + val mlsStore = SnapshotStateStore() + val messageStore = SnapshotMessageStore() + val manager = MarmotManager(signer, mlsStore, messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY) + } + + private suspend fun Fixture.createGroup(secs: ULong?) = + manager.createGroup( + nostrGroupId, + // Version 3: the legacy `0xF2EE` blob only carries + // `disappearing_message_secs` from v3 on, so that v1/v2 stays + // byte-for-byte what MDK's older parser accepts. + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "retention", + relays = listOf("wss://relay.invalid"), + disappearingMessageSecs = secs, + version = 3, + ), + ) + + private suspend fun MarmotManager.storedIds(): List = loadStoredMessages(nostrGroupId).mapNotNull { Event.fromJsonOrNull(it)?.id } + + @Test + fun `a group with no retention keeps its messages`() = + runBlocking { + val f = Fixture() + f.createGroup(null) + val sent = f.manager.buildTextMessage(nostrGroupId, "keep me") + + assertEquals(0L, f.manager.retentionSeconds(nostrGroupId)) + assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, TimeUtils.now() + 10_000_000).isEmpty()) + assertTrue(sent.innerEvent.id in f.manager.storedIds()) + } + + @Test + fun `a message outlives its retention and is deleted, not merely hidden`() = + runBlocking { + val f = Fixture() + f.createGroup(60uL) + val sent = f.manager.buildTextMessage(nostrGroupId, "gone in a minute") + assertEquals(60L, f.manager.retentionSeconds(nostrGroupId)) + + val after = (sent.innerEvent.createdAt) + 61 + assertEquals(setOf(sent.innerEvent.id), f.manager.pruneExpiredMessages(nostrGroupId, after)) + + // Gone from the log itself. A disappearing message that is only + // filtered out of a read is still on disk, and this store holds the + // only copy — the ratchet moved past the ciphertext long ago. + assertFalse(sent.innerEvent.id in f.manager.storedIds()) + assertTrue(f.messageStore.loadExpiries(nostrGroupId).isEmpty()) + } + + @Test + fun `a message inside its window is untouched`() = + runBlocking { + val f = Fixture() + f.createGroup(3600uL) + val sent = f.manager.buildTextMessage(nostrGroupId, "still fresh") + + assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 60).isEmpty()) + assertTrue(sent.innerEvent.id in f.manager.storedIds()) + } + + @Test + fun `expiry is pinned at the source epoch and a later change does not re-time it`() = + runBlocking { + // The rule that is easiest to get wrong: recomputing expiry from + // the CURRENT setting would let one member shorten everyone's + // history retroactively, or restore what should already be gone. + val f = Fixture() + f.createGroup(60uL) + val sent = f.manager.buildTextMessage(nostrGroupId, "pinned at sixty") + + f.manager.updateGroupMetadata( + nostrGroupId, + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "retention", + relays = listOf("wss://relay.invalid"), + disappearingMessageSecs = 86_400uL, + version = 3, + ), + ) + assertEquals(86_400L, f.manager.retentionSeconds(nostrGroupId)) + + // Still expires on the old sixty seconds, not the new day. + assertEquals( + setOf(sent.innerEvent.id), + f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 61), + ) + } + + @Test + fun `re-persisting the same message reuses its pinned expiry`() = + runBlocking { + // The ratchet rewinds on restart and relays replay recent kind:445 + // events, so the same message really is persisted twice. If the + // second write re-timed it, a message could keep postponing its own + // expiry every time it was replayed. + val f = Fixture() + f.createGroup(60uL) + val sent = f.manager.buildTextMessage(nostrGroupId, "replayed") + val pinned = f.messageStore.loadExpiries(nostrGroupId)[sent.innerEvent.id] + + f.manager.updateGroupMetadata( + nostrGroupId, + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "retention", + relays = listOf("wss://relay.invalid"), + disappearingMessageSecs = 86_400uL, + version = 3, + ), + ) + f.manager.persistDecryptedMessage(nostrGroupId, sent.innerEvent.toJson()) + + assertEquals(pinned, f.messageStore.loadExpiries(nostrGroupId)[sent.innerEvent.id]) + } + + @Test + fun `reading a group expires whatever fell due while it was closed`() = + runBlocking { + // The restart case: nothing is running to notice the moment a + // message falls due, so the read itself has to. The message is + // back-dated, which is also the component's documented caveat — + // the base is the sender's own `created_at`, so expiry is only as + // trustworthy as the MLS-authenticated sender. + val f = Fixture() + f.createGroup(60uL) + val stale = + Event( + id = "1".repeat(64), + pubKey = f.signer.pubKey, + createdAt = TimeUtils.now() - 3600, + kind = 9, + tags = emptyArray(), + content = "sent an hour ago", + sig = "", + ) + f.manager.persistDecryptedMessage(nostrGroupId, stale.toJson()) + + assertFalse(stale.id in f.manager.storedIds()) + } + + @Test + fun `an expiring client tells the front end which messages went`() = + runBlocking { + // A message gone from disk but still on screen has not disappeared. + val f = Fixture() + f.createGroup(60uL) + val sent = f.manager.buildTextMessage(nostrGroupId, "drop me from the view") + val announced = mutableListOf() + f.manager.onMessagesExpired = { _, ids -> announced.addAll(ids) } + + f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 61) + assertEquals(listOf(sent.innerEvent.id), announced) + } + + @Test + fun `the current profile reads retention from its own component`() = + runBlocking { + // The legacy blob only carries the setting from v3, so in practice + // a group created by the reference client expresses it as component + // `0x8005` instead. Both have to reach the same answer or a + // disappearing message stops disappearing at the profile boundary. + val f = Fixture() + f.manager.createCurrentProfileGroup( + nostrGroupId = nostrGroupId, + relays = listOf("wss://relay.invalid"), + retention = MessageRetentionV1(120uL), + ) + assertEquals(120L, f.manager.retentionSeconds(nostrGroupId)) + + val sent = f.manager.buildTextMessage(nostrGroupId, "two minutes") + assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 60).isEmpty()) + assertEquals( + setOf(sent.innerEvent.id), + f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 121), + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt index 1ebd9d9270..9b6f97160d 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stand-ins for the durable stores a MarmotManager needs. // @@ -67,6 +68,7 @@ class SnapshotStateStore : MlsGroupStateStore { class SnapshotMessageStore : MarmotMessageStore { private val messages = mutableMapOf>() private val snapshots = mutableMapOf() + private val expiries = mutableMapOf>() override suspend fun appendMessage( nostrGroupId: String, @@ -81,6 +83,7 @@ class SnapshotMessageStore : MarmotMessageStore { override suspend fun delete(nostrGroupId: String) { messages.remove(nostrGroupId) snapshots.remove(nostrGroupId) + expiries.remove(nostrGroupId) } override suspend fun recordGroupSnapshot( @@ -91,6 +94,27 @@ class SnapshotMessageStore : MarmotMessageStore { } override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshots[nostrGroupId] + + // Disappearing messages. First write wins, mirroring the durable stores: + // an expiry is pinned to its message's own source epoch and a replay must + // not re-time it. + override suspend fun recordExpiry( + nostrGroupId: String, + innerEventId: String, + expiresAtSecs: Long, + ) { + expiries.getOrPut(nostrGroupId) { mutableMapOf() }.putIfAbsent(innerEventId, expiresAtSecs) + } + + override suspend fun loadExpiries(nostrGroupId: String): Map = expiries[nostrGroupId]?.toMap() ?: emptyMap() + + override suspend fun removeMessages( + nostrGroupId: String, + innerEventIds: Set, + ) { + messages[nostrGroupId]?.removeAll { json -> Event.fromJsonOrNull(json)?.id in innerEventIds } + expiries[nostrGroupId]?.keys?.removeAll(innerEventIds) + } } class SnapshotBundleStore : KeyPackageBundleStore { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt index 82f056316b..a2b3959bed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt @@ -111,4 +111,43 @@ interface MarmotMessageStore { /** The last recorded snapshot, or null when there is no baseline yet. */ suspend fun loadGroupSnapshot(nostrGroupId: String): String? = null + + // ── Disappearing messages ──────────────────────────────────────────────── + // + // The three members below are one feature and are implemented together or + // not at all: expiries that are never recorded read back empty, and an + // empty set is never removed. A store that implements none of them simply + // keeps every message forever, which is a client that cannot honour + // `marmot.group.message-retention.v1` — degraded, not broken, and the same + // shape of optionality as [recordEpoch]. + // + // Expiry is pinned per message rather than recomputed: the component says + // a message keeps the retention of its OWN source epoch, so a later change + // must not re-time a message that already exists. + + /** + * Remember when [innerEventId] stops being displayable. + * + * @param expiresAtSecs absolute Unix seconds, already `created_at + secs`. + */ + suspend fun recordExpiry( + nostrGroupId: String, + innerEventId: String, + expiresAtSecs: Long, + ) = Unit + + /** Inner event id → its pinned expiry, for what was recorded. */ + suspend fun loadExpiries(nostrGroupId: String): Map = emptyMap() + + /** + * Delete these messages, and any expiry recorded for them, permanently. + * + * The deletion is the feature: a disappearing message that is merely + * hidden is still on disk, and this store is the only durable copy — the + * MLS ratchet has long since moved past the ciphertext it came from. + */ + suspend fun removeMessages( + nostrGroupId: String, + innerEventIds: Set, + ) = Unit }